From 768eaf5b2d79958f6e13a0e94f2899ac7afc8ba8 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:05:53 +0300 Subject: [PATCH 01/33] feat(annotate): expose annotation panel tools --- src/modules/annotate/mod.rs | 40 ++++++- src/ui/ribbon/annotation_panel.rs | 170 ++++++++++++++++++++++++++++++ src/ui/ribbon/draw_panel.rs | 6 ++ src/ui/ribbon/mod.rs | 1 + 4 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 src/ui/ribbon/annotation_panel.rs diff --git a/src/modules/annotate/mod.rs b/src/modules/annotate/mod.rs index 6ff2016ae..c9e783fc2 100644 --- a/src/modules/annotate/mod.rs +++ b/src/modules/annotate/mod.rs @@ -42,7 +42,7 @@ impl CadModule for AnnotateModule { } fn ribbon_groups(&self) -> &[RibbonGroup] { - use crate::modules::draw::draw::{revcloud, wipeout}; + use crate::modules::draw::draw::{centerline, dimcenter, revcloud, wipeout}; static GROUPS: std::sync::OnceLock> = std::sync::OnceLock::new(); GROUPS.get_or_init(|| { @@ -148,6 +148,44 @@ impl CadModule for AnnotateModule { }, ], }, + // ── Centerlines ─────────────────────────────────────────────── + RibbonGroup { + title: "Centerlines", + tools: vec![ + RibbonItem::LargeTool(dimcenter::tool()), + RibbonItem::LargeTool(centerline::tool()), + RibbonItem::Dropdown { + id: "ANNOTATE_CENTER_ASSOCIATIVITY", + icon: crate::modules::IconKind::Svg(include_bytes!( + "../../../assets/icons/line.svg" + )), + items: vec![ + ( + "CENTERREASSOCIATE", + "Reassociate Center Object", + crate::modules::IconKind::Svg(include_bytes!( + "../../../assets/icons/line.svg" + )), + ), + ( + "CENTERDISASSOCIATE", + "Disassociate Center Object", + crate::modules::IconKind::Svg(include_bytes!( + "../../../assets/icons/line.svg" + )), + ), + ( + "CENTERRESET", + "Reset Center Object", + crate::modules::IconKind::Svg(include_bytes!( + "../../../assets/icons/line.svg" + )), + ), + ], + default: "CENTERREASSOCIATE", + }, + ], + }, // ── Leaders ────────────────────────────────────────────────── RibbonGroup { title: "Leaders", diff --git a/src/ui/ribbon/annotation_panel.rs b/src/ui/ribbon/annotation_panel.rs new file mode 100644 index 000000000..7b0a8839c --- /dev/null +++ b/src/ui/ribbon/annotation_panel.rs @@ -0,0 +1,170 @@ +//! Additional annotation tools reached from the Draw tab's Annotation panel title. + +use super::draw_panel::Tool; + +pub(super) const TOOLS: &[Tool] = &[ + Tool { + command: "DDEDIT", + label: "Edit Text", + icon: include_bytes!("../../../assets/icons/ddedit.svg"), + options: &[], + }, + Tool { + command: "TOLERANCE", + label: "Geometric Tolerance", + icon: include_bytes!("../../../assets/icons/tolerance.svg"), + options: &[], + }, + Tool { + command: "DIMALIGNED", + label: "Aligned Dimension", + icon: include_bytes!("../../../assets/icons/dim_aligned.svg"), + options: &[], + }, + Tool { + command: "DIMARC", + label: "Arc Length Dimension", + icon: include_bytes!("../../../assets/icons/dim_angular.svg"), + options: &[], + }, + Tool { + command: "DIMJOGGED", + label: "Jogged Radius Dimension", + icon: include_bytes!("../../../assets/icons/dim_jog.svg"), + options: &[], + }, + Tool { + command: "DIMDIAMETER", + label: "Diameter Dimension", + icon: include_bytes!("../../../assets/icons/dim_diameter.svg"), + options: &[], + }, + Tool { + command: "DIMORDINATE", + label: "Ordinate Dimension", + icon: include_bytes!("../../../assets/icons/dim_ordinate.svg"), + options: &[], + }, + Tool { + command: "QDIM", + label: "Quick Dimension", + icon: include_bytes!("../../../assets/icons/qdim.svg"), + options: &[], + }, + Tool { + command: "DIMCONTINUE", + label: "Continue Dimension", + icon: include_bytes!("../../../assets/icons/dim_continue.svg"), + options: &[], + }, + Tool { + command: "DIMBASELINE", + label: "Baseline Dimension", + icon: include_bytes!("../../../assets/icons/dim_baseline.svg"), + options: &[], + }, + Tool { + command: "DIMBREAK", + label: "Dimension Break", + icon: include_bytes!("../../../assets/icons/dim_break.svg"), + options: &[], + }, + Tool { + command: "DIMEDIT", + label: "Edit Dimension", + icon: include_bytes!("../../../assets/icons/dim_edit.svg"), + options: &[], + }, + Tool { + command: "DIMTEDIT", + label: "Edit Dimension Text", + icon: include_bytes!("../../../assets/icons/dim_tedit.svg"), + options: &[], + }, + Tool { + command: "DIMSPACE", + label: "Adjust Dimension Space", + icon: include_bytes!("../../../assets/icons/dim_space.svg"), + options: &[], + }, + Tool { + command: "DIMJOGLINE", + label: "Add or Remove Jog Line", + icon: include_bytes!("../../../assets/icons/dim_jog.svg"), + options: &[], + }, + Tool { + command: "MLEADERADD", + label: "Add Leader", + icon: include_bytes!("../../../assets/icons/mleader_add.svg"), + options: &[], + }, + Tool { + command: "MLEADERREMOVE", + label: "Remove Leader", + icon: include_bytes!("../../../assets/icons/mleader_remove.svg"), + options: &[], + }, + Tool { + command: "MLEADERALIGN", + label: "Align Leaders", + icon: include_bytes!("../../../assets/icons/mleader_align.svg"), + options: &[], + }, + Tool { + command: "MLEADERCOLLECT", + label: "Collect Leaders", + icon: include_bytes!("../../../assets/icons/mleader_collect.svg"), + options: &[], + }, + Tool { + command: "WIPEOUT", + label: "Wipeout", + icon: include_bytes!("../../../assets/icons/wipeout.svg"), + options: &[], + }, + Tool { + command: "TABLE", + label: "Table and Data", + icon: include_bytes!("../../../assets/icons/table.svg"), + options: &[ + ("TABLE", "Table"), + ("DATALINK", "Link Data"), + ("DATAEXTRACTION", "Extract Data"), + ], + }, + Tool { + command: "CENTERMARK", + label: "Center Mark and Line", + icon: include_bytes!("../../../assets/icons/line.svg"), + options: &[ + ("CENTERMARK", "Center Mark"), + ("CENTERLINE", "Center Line"), + ("CENTERREASSOCIATE", "Reassociate Center Object"), + ("CENTERDISASSOCIATE", "Disassociate Center Object"), + ("CENTERRESET", "Reset Center Object"), + ], + }, + Tool { + command: "REVCLOUD", + label: "Revision Cloud", + icon: include_bytes!("../../../assets/icons/revcloud.svg"), + options: &[ + ("REVCLOUD_RECTANGULAR", "Rectangular"), + ("REVCLOUD_POLYGONAL", "Polygonal"), + ("REVCLOUD_FREEHAND", "Freehand"), + ], + }, + Tool { + command: "OBJECTSCALE", + label: "Annotation Scales", + icon: include_bytes!("../../../assets/icons/add_scale.svg"), + options: &[ + ("OBJECTSCALE ADD", "Add Current Scale"), + ("OBJECTSCALE DELETE", "Delete Current Scale"), + ("OBJECTSCALE", "Add or Delete Scales"), + ("SCALELISTEDIT", "Edit Scale List"), + ("ANNORESET", "Sync Scale Positions"), + ], + }, +]; diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index f2e4677c4..897dd528a 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -140,6 +140,12 @@ const PANELS: &[Panel] = &[ title: "Modify", tools: super::modify_panel::TOOLS, }, + Panel { + id: "annotation_extension", + title_id: "annotation_extension_title", + title: "Annotation", + tools: super::annotation_panel::TOOLS, + }, ]; fn panel_for_dropdown(id: &str) -> Option { diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index a5eb2ffe3..d57cf6c22 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -21,6 +21,7 @@ use crate::plugin::all_ribbon_modules; use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem}; mod widgets; +mod annotation_panel; mod draw_panel; mod modify_panel; use widgets::{StyleContext, *}; From ec14c98f07cc6bd7a42befe7e04a6e7fea8fc790 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:59:20 +0300 Subject: [PATCH 02/33] fix(annotate): keep tools in their ribbon tab --- src/ui/ribbon/annotation_panel.rs | 170 ------------------------------ src/ui/ribbon/draw_panel.rs | 6 -- src/ui/ribbon/mod.rs | 1 - 3 files changed, 177 deletions(-) delete mode 100644 src/ui/ribbon/annotation_panel.rs diff --git a/src/ui/ribbon/annotation_panel.rs b/src/ui/ribbon/annotation_panel.rs deleted file mode 100644 index 7b0a8839c..000000000 --- a/src/ui/ribbon/annotation_panel.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! Additional annotation tools reached from the Draw tab's Annotation panel title. - -use super::draw_panel::Tool; - -pub(super) const TOOLS: &[Tool] = &[ - Tool { - command: "DDEDIT", - label: "Edit Text", - icon: include_bytes!("../../../assets/icons/ddedit.svg"), - options: &[], - }, - Tool { - command: "TOLERANCE", - label: "Geometric Tolerance", - icon: include_bytes!("../../../assets/icons/tolerance.svg"), - options: &[], - }, - Tool { - command: "DIMALIGNED", - label: "Aligned Dimension", - icon: include_bytes!("../../../assets/icons/dim_aligned.svg"), - options: &[], - }, - Tool { - command: "DIMARC", - label: "Arc Length Dimension", - icon: include_bytes!("../../../assets/icons/dim_angular.svg"), - options: &[], - }, - Tool { - command: "DIMJOGGED", - label: "Jogged Radius Dimension", - icon: include_bytes!("../../../assets/icons/dim_jog.svg"), - options: &[], - }, - Tool { - command: "DIMDIAMETER", - label: "Diameter Dimension", - icon: include_bytes!("../../../assets/icons/dim_diameter.svg"), - options: &[], - }, - Tool { - command: "DIMORDINATE", - label: "Ordinate Dimension", - icon: include_bytes!("../../../assets/icons/dim_ordinate.svg"), - options: &[], - }, - Tool { - command: "QDIM", - label: "Quick Dimension", - icon: include_bytes!("../../../assets/icons/qdim.svg"), - options: &[], - }, - Tool { - command: "DIMCONTINUE", - label: "Continue Dimension", - icon: include_bytes!("../../../assets/icons/dim_continue.svg"), - options: &[], - }, - Tool { - command: "DIMBASELINE", - label: "Baseline Dimension", - icon: include_bytes!("../../../assets/icons/dim_baseline.svg"), - options: &[], - }, - Tool { - command: "DIMBREAK", - label: "Dimension Break", - icon: include_bytes!("../../../assets/icons/dim_break.svg"), - options: &[], - }, - Tool { - command: "DIMEDIT", - label: "Edit Dimension", - icon: include_bytes!("../../../assets/icons/dim_edit.svg"), - options: &[], - }, - Tool { - command: "DIMTEDIT", - label: "Edit Dimension Text", - icon: include_bytes!("../../../assets/icons/dim_tedit.svg"), - options: &[], - }, - Tool { - command: "DIMSPACE", - label: "Adjust Dimension Space", - icon: include_bytes!("../../../assets/icons/dim_space.svg"), - options: &[], - }, - Tool { - command: "DIMJOGLINE", - label: "Add or Remove Jog Line", - icon: include_bytes!("../../../assets/icons/dim_jog.svg"), - options: &[], - }, - Tool { - command: "MLEADERADD", - label: "Add Leader", - icon: include_bytes!("../../../assets/icons/mleader_add.svg"), - options: &[], - }, - Tool { - command: "MLEADERREMOVE", - label: "Remove Leader", - icon: include_bytes!("../../../assets/icons/mleader_remove.svg"), - options: &[], - }, - Tool { - command: "MLEADERALIGN", - label: "Align Leaders", - icon: include_bytes!("../../../assets/icons/mleader_align.svg"), - options: &[], - }, - Tool { - command: "MLEADERCOLLECT", - label: "Collect Leaders", - icon: include_bytes!("../../../assets/icons/mleader_collect.svg"), - options: &[], - }, - Tool { - command: "WIPEOUT", - label: "Wipeout", - icon: include_bytes!("../../../assets/icons/wipeout.svg"), - options: &[], - }, - Tool { - command: "TABLE", - label: "Table and Data", - icon: include_bytes!("../../../assets/icons/table.svg"), - options: &[ - ("TABLE", "Table"), - ("DATALINK", "Link Data"), - ("DATAEXTRACTION", "Extract Data"), - ], - }, - Tool { - command: "CENTERMARK", - label: "Center Mark and Line", - icon: include_bytes!("../../../assets/icons/line.svg"), - options: &[ - ("CENTERMARK", "Center Mark"), - ("CENTERLINE", "Center Line"), - ("CENTERREASSOCIATE", "Reassociate Center Object"), - ("CENTERDISASSOCIATE", "Disassociate Center Object"), - ("CENTERRESET", "Reset Center Object"), - ], - }, - Tool { - command: "REVCLOUD", - label: "Revision Cloud", - icon: include_bytes!("../../../assets/icons/revcloud.svg"), - options: &[ - ("REVCLOUD_RECTANGULAR", "Rectangular"), - ("REVCLOUD_POLYGONAL", "Polygonal"), - ("REVCLOUD_FREEHAND", "Freehand"), - ], - }, - Tool { - command: "OBJECTSCALE", - label: "Annotation Scales", - icon: include_bytes!("../../../assets/icons/add_scale.svg"), - options: &[ - ("OBJECTSCALE ADD", "Add Current Scale"), - ("OBJECTSCALE DELETE", "Delete Current Scale"), - ("OBJECTSCALE", "Add or Delete Scales"), - ("SCALELISTEDIT", "Edit Scale List"), - ("ANNORESET", "Sync Scale Positions"), - ], - }, -]; diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index 897dd528a..f2e4677c4 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -140,12 +140,6 @@ const PANELS: &[Panel] = &[ title: "Modify", tools: super::modify_panel::TOOLS, }, - Panel { - id: "annotation_extension", - title_id: "annotation_extension_title", - title: "Annotation", - tools: super::annotation_panel::TOOLS, - }, ]; fn panel_for_dropdown(id: &str) -> Option { diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index d57cf6c22..a5eb2ffe3 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -21,7 +21,6 @@ use crate::plugin::all_ribbon_modules; use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem}; mod widgets; -mod annotation_panel; mod draw_panel; mod modify_panel; use widgets::{StyleContext, *}; From 4ce42f9cdbd3260f1818a04beb5a3b5bd33b8219 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:00:59 +0300 Subject: [PATCH 03/33] fix(draw): remove cross-tab extension tools --- src/ui/ribbon/draw_panel.rs | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index f2e4677c4..54c6ed688 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -73,12 +73,6 @@ const TOOLS: &[Tool] = &[ icon: include_bytes!("../../../assets/icons/region.svg"), options: &[], }, - Tool { - command: "WIPEOUT", - label: "Wipeout", - icon: include_bytes!("../../../assets/icons/wipeout.svg"), - options: &[], - }, Tool { command: "BOUNDARY", label: "Boundary", @@ -107,16 +101,6 @@ const TOOLS: &[Tool] = &[ ("DDPTYPE", "Point Style"), ], }, - Tool { - command: "REVCLOUD", - label: "Revision Cloud", - icon: include_bytes!("../../../assets/icons/revcloud.svg"), - options: &[ - ("REVCLOUD_RECTANGULAR", "Rectangular"), - ("REVCLOUD_POLYGONAL", "Polygonal"), - ("REVCLOUD_FREEHAND", "Freehand"), - ], - }, ]; #[derive(Clone, Copy)] @@ -305,7 +289,7 @@ mod tests { #[test] fn extension_submenus_resolve_to_their_parent_panel() { - assert_eq!(parent_panel("REVCLOUD"), Some(PANEL_ID)); + assert_eq!(parent_panel("MULTIPOINT"), Some(PANEL_ID)); assert_eq!(parent_panel("DRAWORDER_FRONT"), Some("modify_extension")); assert_eq!(parent_panel("unknown"), None); } From dfcae61ca70e6eb7bffbbdfdd0182823e37defae Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:01:29 +0300 Subject: [PATCH 04/33] fix(modify): remove cleanup tab duplicate --- src/ui/ribbon/modify_panel.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/ui/ribbon/modify_panel.rs b/src/ui/ribbon/modify_panel.rs index 835e03c27..04eee0f55 100644 --- a/src/ui/ribbon/modify_panel.rs +++ b/src/ui/ribbon/modify_panel.rs @@ -67,12 +67,6 @@ pub(super) const TOOLS: &[Tool] = &[ icon: include_bytes!("../../../assets/icons/modify_ncopy.svg"), options: &[], }, - Tool { - command: "OVERKILL", - label: "Delete Duplicate Objects", - icon: include_bytes!("../../../assets/icons/modify_overkill.svg"), - options: &[], - }, Tool { command: "DRAWORDER_FRONT", label: "Draw Order", From 753df67ee48b2881aab2ee41079920ead50c0095 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 14 Sep 2026 23:51:37 +0300 Subject: [PATCH 05/33] Complete parametric drawing interoperability --- Cargo.lock | 6 +- Cargo.toml | 6 +- crates/ocs_plugin_api/Cargo.toml | 4 +- crates/ocs_web_worker/Cargo.toml | 2 +- crates/plugin-template-api2/Cargo.toml | 2 +- docs/plugin-template-v2/Cargo.toml | 2 +- src/app/automation.rs | 2 +- src/app/update/file.rs | 7 +- src/scene/dwg_native_constraints.rs | 1014 ++++++++++++++++++++++-- src/scene/sketch_constraints.rs | 16 + src/scene/sketch_persist.rs | 34 +- src/scene/sketch_solve.rs | 439 ++++++++-- tests/sketch_constraints_solve.rs | 206 +++++ 13 files changed, 1602 insertions(+), 138 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 49635a72c..5d95a48f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,7 +83,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=4d7b359b0571f9b6e0f85a62a21c67508722b83e#4d7b359b0571f9b6e0f85a62a21c67508722b83e" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=7709884f301cfe292f58a002a514939932be67d0#7709884f301cfe292f58a002a514939932be67d0" dependencies = [ "ahash 0.8.12", "anyhow", @@ -919,7 +919,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=50f020377c01fcebb5f31844b9900f046b05192f#50f020377c01fcebb5f31844b9900f046b05192f" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b#2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b" dependencies = [ "acadrust", "cavalier_contours", @@ -930,7 +930,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=50f020377c01fcebb5f31844b9900f046b05192f#50f020377c01fcebb5f31844b9900f046b05192f" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b#2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 1467496a5..4a83d5bcd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,9 +32,9 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "50f020377c01fcebb5f31844b9900f046b05192f", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "50f020377c01fcebb5f31844b9900f046b05192f" } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index aea33085c..854dc014b 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -38,7 +38,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 809c2090a..0bb5e1460 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml index 290103605..bcb242dce 100644 --- a/crates/plugin-template-api2/Cargo.toml +++ b/crates/plugin-template-api2/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", features = ["serde"] } diff --git a/docs/plugin-template-v2/Cargo.toml b/docs/plugin-template-v2/Cargo.toml index f46779ba5..300cf870a 100644 --- a/docs/plugin-template-v2/Cargo.toml +++ b/docs/plugin-template-v2/Cargo.toml @@ -12,4 +12,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e" } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0" } diff --git a/src/app/automation.rs b/src/app/automation.rs index a862a29cf..7089fead6 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -317,8 +317,8 @@ impl OpenCADStudio { let i = self.active_tab; self.tabs[i].scene.clear(); self.tabs[i].scene.document = doc; - self.tabs[i].scene.load_sketch_constraints_from_document(); self.tabs[i].scene.load_named_parameters_from_document(); + self.tabs[i].scene.load_sketch_constraints_from_document(); self.tabs[i].scene.material_base_dir = path_buf.parent().map(PathBuf::from); crate::app::style_ops::ensure_standard_styles( &mut self.tabs[i].scene.document, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 1bad3d3ce..7487d5b75 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1446,11 +1446,10 @@ pub(super) fn on_open_file(&mut self) -> Task { self.tabs[i].scene.material_base_dir = path.parent().map(std::path::Path::to_path_buf); self.tabs[i].scene.document = doc; - // Load persisted constraints after installing the document. - self.tabs[i].scene.load_sketch_constraints_from_document(); - // named_parameters_design.md stage 2: same load-time hook, - // for the document-wide parameter table. + // Load parameters first so imported dimensional constraints + // can resolve their named driving values. self.tabs[i].scene.load_named_parameters_from_document(); + self.tabs[i].scene.load_sketch_constraints_from_document(); self.tabs[i].active_layer = self.tabs[i] .scene .document diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index 0f7d76cec..11e0a7da7 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -12,7 +12,9 @@ use acadrust::{CadDocument, EntityType}; use rustc_hash::FxHashMap; use super::named_parameters::{DrivingValue, ParameterTable}; -use super::sketch_constraints::{ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef}; +use super::sketch_constraints::{ + ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef, SketchScope, +}; use super::Scene; /// Default sub-dictionary key used for an associative network. @@ -39,6 +41,7 @@ mod implicit_point_type { struct EntityNodes { geometry_node_id: i32, points: FxHashMap, + segments: FxHashMap, } /// Builds one scope's `Assoc2dConstraintGroup` graph incrementally, @@ -51,6 +54,8 @@ struct GroupBuilder<'a> { /// Real entity handles that ended up with a geometry node — what /// `AssocGeomDependency` objects get created for, in first-touch order. referenced_entities: Vec, + horizontal_datum: Option, + vertical_datum: Option, } impl<'a> GroupBuilder<'a> { @@ -61,6 +66,8 @@ impl<'a> GroupBuilder<'a> { next_node_id: FIRST_NODE_ID, entities: FxHashMap::default(), referenced_entities: Vec::new(), + horizontal_datum: None, + vertical_datum: None, } } @@ -92,24 +99,7 @@ impl<'a> GroupBuilder<'a> { }); } - /// The geometry node id for `entity`'s whole shape, creating a `Line` - /// (well: `BoundedLine`, §4 — nothing we draw is an infinite - /// construction line), `Circle`, or `Arc` node the first time this - /// entity is referenced. Returns `None` for an entity kind this pass - /// doesn't support as constraint geometry — currently including - /// `Ellipse` (constraint-parity Phase 5): the solver - /// (`sketch_solve.rs`) and this app's own XRecord format both support - /// ellipse-referencing constraints already, but `acadrust`'s - /// `AssocConstraintNodeData::Ellipse`/`BoundedEllipse` variants have a - /// different field shape from `Circle`/`Arc`'s here (`owner_id`/ - /// `is_implied`/`is_active` instead of `geometry_dependency`/ - /// `geometry_node_id`, i.e. they don't obviously look like the same - /// "geometry dependency for an entity reference" role) — without - /// confirming that against a real externally written file, guessing would - /// risk writing a wrong-but-plausible-looking object graph, which is - /// worse than the honest "not persisted to DWG yet" gap this already - /// degrades to (same contract as `ArcLength`, which has no native - /// representation at all). + /// Returns the whole-geometry node for an entity, creating it once. fn geometry_node(&mut self, handle: Handle) -> Option { if let Some(existing) = self.entities.get(&handle) { if existing.geometry_node_id != 0 { @@ -119,6 +109,71 @@ impl<'a> GroupBuilder<'a> { let entity = self.document.get_entity(handle)?; let node_id = self.alloc_node_id(); match entity { + EntityType::Point(point) => { + self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(point.location), + }, + ); + } + EntityType::Insert(insert) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(insert.insert_point), + }, + ), + EntityType::Text(text) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(text.insertion_point), + }, + ), + EntityType::MText(text) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(text.insertion_point), + }, + ), + EntityType::AttributeDefinition(attribute) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(attribute.insertion_point), + }, + ), + EntityType::AttributeEntity(attribute) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(attribute.insertion_point), + }, + ), + EntityType::Table(table) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(table.insertion_point), + }, + ), EntityType::Line(l) => { let point = l.start; let direction = (l.end - l.start).normalize(); @@ -187,6 +242,50 @@ impl<'a> GroupBuilder<'a> { }, ); } + EntityType::Ellipse(e) => { + let major_length = e.major_axis.length(); + let major = if major_length > f64::EPSILON { + e.major_axis / major_length + } else { + Vector3::UNIT_X + }; + let normal = e.normal.normalize(); + let minor = Vector3::new( + normal.y * major.z - normal.z * major.y, + normal.z * major.x - normal.x * major.z, + normal.x * major.y - normal.y * major.x, + ); + let point_at = |parameter: f64| { + e.center + + e.major_axis * parameter.cos() + + minor * (major_length * e.minor_axis_ratio * parameter.sin()) + }; + let data = if e.is_full() { + AssocConstraintNodeData::Ellipse { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + center: e.center, + major_axis: e.major_axis, + axis_ratio: e.minor_axis_ratio, + } + } else { + AssocConstraintNodeData::BoundedEllipse { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + center: e.center, + major_axis: e.major_axis, + axis_ratio: e.minor_axis_ratio, + start_point: point_at(e.start_parameter), + end_point: point_at(e.end_parameter), + } + }; + let class_name = if e.is_full() { + "AcConstrainedEllipse" + } else { + "AcConstrainedBoundedEllipse" + }; + self.push_node(node_id, class_name, data); + } _ => return None, } self.entities.entry(handle).or_default().geometry_node_id = node_id; @@ -206,12 +305,37 @@ impl<'a> GroupBuilder<'a> { { return Some(*existing); } - let curve_id = self.geometry_node(handle)?; - let point_type = match marker { - 0 => implicit_point_type::START, - 1 => implicit_point_type::END, - -3 => implicit_point_type::CENTER, - _ => return None, + let polyline = matches!( + self.document.get_entity(handle), + Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_)) + ); + let (curve_id, point_type) = if polyline && marker >= 0 { + let entity = self.document.get_entity(handle)?; + let points = super::dimension_assoc::source_points(entity); + let closed = match entity { + EntityType::LwPolyline(polyline) => polyline.is_closed, + EntityType::Polyline2D(polyline) => polyline.is_closed(), + _ => false, + }; + let vertex = marker as usize; + if vertex >= points.len() { + return None; + } + let (segment, point_type) = if vertex + 1 < points.len() || closed { + (vertex, implicit_point_type::START) + } else { + (vertex.checked_sub(1)?, implicit_point_type::END) + }; + (self.segment_node(handle, segment)?, point_type) + } else { + let curve_id = self.geometry_node(handle)?; + let point_type = match marker { + 0 => implicit_point_type::START, + 1 => implicit_point_type::END, + -3 => implicit_point_type::CENTER, + _ => return None, + }; + (curve_id, point_type) }; let node_id = self.alloc_node_id(); self.push_node( @@ -226,6 +350,22 @@ impl<'a> GroupBuilder<'a> { curve_id, }, ); + let relation_id = self.alloc_node_id(); + self.push_node( + relation_id, + if point_type == implicit_point_type::CENTER { + "AcCenterPointConstraint" + } else { + "AcPointCurveConstraint" + }, + AssocConstraintNodeData::Geometrical { + owner_id: 0, + is_implied: false, + is_active: true, + }, + ); + self.connect(relation_id, node_id); + self.connect(relation_id, curve_id); self.entities .entry(handle) .or_default() @@ -237,12 +377,121 @@ impl<'a> GroupBuilder<'a> { /// The node id `SketchRef` resolves to: a point node for a marked /// reference, the whole geometry node otherwise. fn ref_node(&mut self, r: SketchRef) -> Option { + if let Some(segment) = r.segment_index() { + return self.segment_node(r.entity, segment); + } + if r.marker == Some(0) + && matches!( + self.document.get_entity(r.entity), + Some( + EntityType::Point(_) + | EntityType::Insert(_) + | EntityType::Text(_) + | EntityType::MText(_) + | EntityType::AttributeDefinition(_) + | EntityType::AttributeEntity(_) + | EntityType::Table(_) + ) + ) + { + return self.geometry_node(r.entity); + } match r.marker { Some(marker) => self.point_node(r.entity, marker), None => self.geometry_node(r.entity), } } + fn segment_node(&mut self, handle: Handle, index: usize) -> Option { + if let Some(node_id) = self + .entities + .get(&handle) + .and_then(|entity| entity.segments.get(&index)) + { + return Some(*node_id); + } + let entity = self.document.get_entity(handle)?; + let points = super::dimension_assoc::source_points(entity); + let closed = match entity { + EntityType::LwPolyline(polyline) => { + if polyline.vertices.get(index)?.bulge.abs() > 1e-9 { + return None; + } + polyline.is_closed + } + EntityType::Polyline2D(polyline) => { + if polyline.vertices.get(index)?.bulge.abs() > 1e-9 { + return None; + } + polyline.is_closed() + } + _ => return None, + }; + let start = *points.get(index)?; + let end = if index + 1 < points.len() { + points[index + 1] + } else if closed { + *points.first()? + } else { + return None; + }; + let node_id = self.alloc_node_id(); + self.push_node( + node_id, + "AcConstrainedBoundedLine", + AssocConstraintNodeData::BoundedLine { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: start, + direction: (end - start).normalize(), + is_ray: false, + start_point: start, + end_point: end, + }, + ); + self.entities + .entry(handle) + .or_default() + .segments + .insert(index, node_id); + if !self.referenced_entities.contains(&handle) { + self.referenced_entities.push(handle); + } + Some(node_id) + } + + fn datum_line(&mut self, horizontal: bool) -> i32 { + let cached = if horizontal { + self.horizontal_datum + } else { + self.vertical_datum + }; + if let Some(node_id) = cached { + return node_id; + } + let node_id = self.alloc_node_id(); + self.push_node( + node_id, + "AcConstrainedDatumLine", + AssocConstraintNodeData::Line { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Vector3::ZERO, + direction: if horizontal { + Vector3::UNIT_X + } else { + Vector3::UNIT_Y + }, + }, + ); + if horizontal { + self.horizontal_datum = Some(node_id); + } else { + self.vertical_datum = Some(node_id); + } + node_id + } + /// Records an undirected edge on both nodes. fn connect(&mut self, a: i32, b: i32) { if let Some(node) = self.nodes.iter_mut().find(|n| n.node_id == a) { @@ -258,8 +507,6 @@ impl<'a> GroupBuilder<'a> { fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { match kind { ConstraintKind::Coincident => Some("AcPointCoincidenceConstraint"), - ConstraintKind::Horizontal => Some("AcHorizontalConstraint"), - ConstraintKind::Vertical => Some("AcVerticalConstraint"), ConstraintKind::Perpendicular => Some("AcPerpendicularConstraint"), ConstraintKind::Tangent => Some("AcTangentConstraint"), ConstraintKind::Smooth => Some("AcG2SmoothConstraint"), @@ -281,6 +528,8 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { ConstraintKind::EqualDistance => Some("AcEqualDistanceConstraint"), // `Equal` needs the resolved entity types, so the caller handles it. ConstraintKind::Equal + | ConstraintKind::Horizontal + | ConstraintKind::Vertical | ConstraintKind::Parallel | ConstraintKind::Distance | ConstraintKind::Angle @@ -353,6 +602,36 @@ fn constraint_node( return Some(node_id); } match constraint.kind { + ConstraintKind::Horizontal | ConstraintKind::Vertical => { + if !matches!(refs.len(), 1 | 2) { + return None; + } + let targets: Vec<_> = refs + .iter() + .map(|reference| builder.ref_node(*reference)) + .collect::>()?; + let horizontal = constraint.kind == ConstraintKind::Horizontal; + let datum = builder.datum_line(horizontal); + let node_id = builder.alloc_node_id(); + builder.push_node( + node_id, + if horizontal { + "AcHorizontalConstraint" + } else { + "AcVerticalConstraint" + }, + AssocConstraintNodeData::Parallel { + owner_id: targets[0], + is_implied: false, + is_active: true, + datum_line_index: Some(datum), + }, + ); + for target in targets { + builder.connect(node_id, target); + } + Some(node_id) + } ConstraintKind::Parallel => { let [a, b] = refs else { return None }; let (na, nb) = (builder.ref_node(*a)?, builder.ref_node(*b)?); @@ -374,8 +653,17 @@ fn constraint_node( ConstraintKind::Equal => { let [a, b] = refs else { return None }; let (na, nb) = (builder.ref_node(*a)?, builder.ref_node(*b)?); - let both_lines = matches!(document.get_entity(a.entity), Some(EntityType::Line(_))) - && matches!(document.get_entity(b.entity), Some(EntityType::Line(_))); + let is_straight = |reference: &SketchRef| { + matches!( + document.get_entity(reference.entity), + Some(EntityType::Line(_)) + ) || (reference.segment_index().is_some() + && matches!( + document.get_entity(reference.entity), + Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_)) + )) + }; + let both_lines = is_straight(a) && is_straight(b); let class_name = if both_lines { "AcEqualLengthConstraint" } else { @@ -657,7 +945,11 @@ impl Allocator<'_> { /// null dependency and refer to their owning geometry node by `curve_id`. fn set_geometry_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { match data { - AssocConstraintNodeData::BoundedLine { + AssocConstraintNodeData::Point { + geometry_dependency, + .. + } + | AssocConstraintNodeData::BoundedLine { geometry_dependency, .. } @@ -668,6 +960,14 @@ fn set_geometry_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { | AssocConstraintNodeData::Arc { geometry_dependency, .. + } + | AssocConstraintNodeData::Ellipse { + geometry_dependency, + .. + } + | AssocConstraintNodeData::BoundedEllipse { + geometry_dependency, + .. } => { *geometry_dependency = handle; } @@ -848,6 +1148,413 @@ fn has_native_network(document: &CadDocument, owner: Handle) -> bool { dictionary.get(NETWORK_DICTIONARY_KEY).is_some() } +fn native_group(document: &CadDocument, owner: Handle) -> Option<&Assoc2dConstraintGroup> { + let network_handle = native_scope_network_handle(document, owner)?; + document.objects.values().find_map(|object| { + let ObjectType::Associative(object) = object else { + return None; + }; + let AssociativeData::ConstraintGroup(group) = &object.data else { + return None; + }; + (object.owner == network_handle || group.action.owning_network == network_handle) + .then_some(group) + }) +} + +fn native_scope_network_handle(document: &CadDocument, owner: Handle) -> Option { + let dictionary = document.extension_dictionary_handle(owner)?; + let ObjectType::Dictionary(dictionary) = document.objects.get(&dictionary)? else { + return None; + }; + let mut network_handle = dictionary.get(NETWORK_DICTIONARY_KEY)?; + while let Some(ObjectType::Dictionary(dictionary)) = document.objects.get(&network_handle) { + network_handle = dictionary.get(NETWORK_DICTIONARY_KEY)?; + } + matches!( + document.objects.get(&network_handle), + Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::Network(_), + .. + })) + ) + .then_some(network_handle) +} + +fn geometry_dependency(data: &AssocConstraintNodeData) -> Option { + match data { + AssocConstraintNodeData::Point { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Line { + geometry_dependency, + .. + } + | AssocConstraintNodeData::BoundedLine { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Circle { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Arc { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Ellipse { + geometry_dependency, + .. + } + | AssocConstraintNodeData::BoundedEllipse { + geometry_dependency, + .. + } if !geometry_dependency.is_null() => Some(*geometry_dependency), + _ => None, + } +} + +fn dependency_entity( + document: &CadDocument, + dependency: Handle, + geometry: &AssocConstraintNodeData, +) -> Option { + let ObjectType::Associative(object) = document.objects.get(&dependency)? else { + return None; + }; + let AssociativeData::GeomDependency(dependency) = &object.data else { + return None; + }; + let entity = dependency.dependency.dependent_on; + let supported = match (document.get_entity(entity), geometry) { + ( + Some( + EntityType::Point(_) + | EntityType::Insert(_) + | EntityType::Text(_) + | EntityType::MText(_) + | EntityType::AttributeDefinition(_) + | EntityType::AttributeEntity(_) + | EntityType::Table(_), + ), + AssocConstraintNodeData::Point { .. }, + ) => true, + ( + Some(EntityType::Line(_) | EntityType::LwPolyline(_) | EntityType::Polyline2D(_)), + AssocConstraintNodeData::Line { .. } | AssocConstraintNodeData::BoundedLine { .. }, + ) => true, + (Some(EntityType::Circle(_)), AssocConstraintNodeData::Circle { .. }) => true, + (Some(EntityType::Arc(_)), AssocConstraintNodeData::Arc { .. }) => true, + ( + Some(EntityType::Ellipse(_)), + AssocConstraintNodeData::Ellipse { .. } + | AssocConstraintNodeData::BoundedEllipse { .. }, + ) => true, + _ => false, + }; + supported.then_some(entity) +} + +fn native_group_requires_preservation(document: &CadDocument, owner: Handle) -> bool { + native_group(document, owner).is_some_and(|group| { + group.nodes.iter().any(|node| { + if matches!(node.data, AssocConstraintNodeData::RigidSet { .. }) { + return true; + } + if let Some(dependency) = geometry_dependency(&node.data) { + if dependency_entity(document, dependency, &node.data).is_none() { + return true; + } + } + node.class_name.to_ascii_uppercase().ends_with("CONSTRAINT") + && constraint_kind(node).is_none() + }) + }) +} + +fn polyline_segment_reference( + document: &CadDocument, + entity: Handle, + data: &AssocConstraintNodeData, +) -> Option { + let AssocConstraintNodeData::BoundedLine { + start_point, + end_point, + .. + } = data + else { + return None; + }; + let entity_value = document.get_entity(entity)?; + let closed = match entity_value { + EntityType::LwPolyline(polyline) => polyline.is_closed, + EntityType::Polyline2D(polyline) => polyline.is_closed(), + _ => return None, + }; + let points = super::dimension_assoc::source_points(entity_value); + let segment_count = points.len().saturating_sub(usize::from(!closed)); + (0..segment_count) + .min_by(|first, second| { + let score = |index: usize| { + let a = points[index]; + let b = points[(index + 1) % points.len()]; + let forward = + (a - *start_point).length_squared() + (b - *end_point).length_squared(); + let reverse = + (b - *start_point).length_squared() + (a - *end_point).length_squared(); + forward.min(reverse) + }; + score(*first).total_cmp(&score(*second)) + }) + .map(|index| SketchRef::segment(entity, index)) +} + +fn numeric_value(value: &AssocEvalVariant) -> Option { + match value.value { + AssocEvalValue::Real(value) => Some(value), + AssocEvalValue::Long(value) => Some(value as f64), + AssocEvalValue::Short(value) => Some(value as f64), + AssocEvalValue::Byte(value) => Some(value as f64), + _ => None, + } +} + +fn driving_value( + document: &CadDocument, + dependency: Handle, + parameters: &mut ParameterTable, +) -> Option { + let ObjectType::Associative(object) = document.objects.get(&dependency)? else { + return None; + }; + let AssociativeData::ValueDependency(value_dependency) = &object.data else { + return None; + }; + let literal = numeric_value(&value_dependency.value); + let ObjectType::Associative(variable) = document + .objects + .get(&value_dependency.dependency.dependent_on)? + else { + return literal.map(DrivingValue::Literal); + }; + let AssociativeData::Variable(variable) = &variable.data else { + return literal.map(DrivingValue::Literal); + }; + let source = if variable.expression.trim().is_empty() { + numeric_value(&variable.value)?.to_string() + } else { + variable.expression.clone() + }; + if parameters.set(&variable.name, &source).is_ok() { + Some(DrivingValue::Named(variable.name.clone())) + } else { + literal + .or_else(|| numeric_value(&variable.value)) + .map(DrivingValue::Literal) + } +} + +fn constraint_owner(data: &AssocConstraintNodeData) -> Option { + match data { + AssocConstraintNodeData::Geometrical { owner_id, .. } + | AssocConstraintNodeData::Angle { owner_id, .. } + | AssocConstraintNodeData::Parallel { owner_id, .. } + | AssocConstraintNodeData::Distance { owner_id, .. } + | AssocConstraintNodeData::RadiusDiameter { owner_id, .. } => Some(*owner_id), + _ => None, + } +} + +fn constraint_is_active(data: &AssocConstraintNodeData) -> bool { + match data { + AssocConstraintNodeData::Geometrical { is_active, .. } + | AssocConstraintNodeData::Angle { is_active, .. } + | AssocConstraintNodeData::Parallel { is_active, .. } + | AssocConstraintNodeData::Distance { is_active, .. } + | AssocConstraintNodeData::RadiusDiameter { is_active, .. } => *is_active, + _ => true, + } +} + +fn constraint_kind(node: &AssocConstraintNode) -> Option { + Some(match node.class_name.to_ascii_uppercase().as_str() { + "ACPOINTCOINCIDENCECONSTRAINT" => ConstraintKind::Coincident, + "ACHORIZONTALCONSTRAINT" => ConstraintKind::Horizontal, + "ACVERTICALCONSTRAINT" => ConstraintKind::Vertical, + "ACPARALLELCONSTRAINT" => ConstraintKind::Parallel, + "ACPERPENDICULARCONSTRAINT" => ConstraintKind::Perpendicular, + "ACEQUALLENGTHCONSTRAINT" | "ACEQUALRADIUSCONSTRAINT" => ConstraintKind::Equal, + "ACANGLECONSTRAINT" | "AC3POINTANGLECONSTRAINT" => ConstraintKind::Angle, + "ACTANGENTCONSTRAINT" => ConstraintKind::Tangent, + "ACG2SMOOTHCONSTRAINT" => ConstraintKind::Smooth, + "ACCONCENTRICCONSTRAINT" => ConstraintKind::Concentric, + "ACCENTERPOINTCONSTRAINT" => ConstraintKind::CenterPoint, + "ACCOLINEARCONSTRAINT" => ConstraintKind::Colinear, + "ACMIDPOINTCONSTRAINT" => ConstraintKind::Midpoint, + "ACFIXEDCONSTRAINT" => ConstraintKind::Fixed, + "ACPOINTCURVECONSTRAINT" => ConstraintKind::PointOnCurve, + "ACEQUALDISTANCECONSTRAINT" => ConstraintKind::EqualDistance, + "ACSYMMETRICCONSTRAINT" => ConstraintKind::Symmetric, + "ACNORMALCONSTRAINT" => ConstraintKind::Normal, + "ACRADIUSDIAMETERCONSTRAINT" => { + let AssocConstraintNodeData::RadiusDiameter { mode, .. } = node.data else { + return None; + }; + if mode == 1 { + ConstraintKind::Diameter + } else { + ConstraintKind::Radius + } + } + "ACDISTANCECONSTRAINT" => { + let AssocConstraintNodeData::Distance { + direction_type, + distance, + .. + } = &node.data + else { + return None; + }; + if *direction_type == 0 { + ConstraintKind::Distance + } else if distance.is_some_and(|direction| direction.x.abs() >= direction.y.abs()) { + ConstraintKind::DistanceX + } else { + ConstraintKind::DistanceY + } + } + _ => return None, + }) +} + +pub(super) fn native_constraint_set( + document: &CadDocument, + owner: Handle, + parameters: &mut ParameterTable, +) -> Option { + let group = native_group(document, owner)?; + let mut refs = FxHashMap::default(); + for node in &group.nodes { + let Some(dependency) = geometry_dependency(&node.data) else { + continue; + }; + if let Some(entity) = dependency_entity(document, dependency, &node.data) { + let reference = polyline_segment_reference(document, entity, &node.data) + .unwrap_or_else(|| { + if matches!(node.data, AssocConstraintNodeData::Point { .. }) { + SketchRef::point(entity, 0) + } else { + SketchRef::whole(entity) + } + }); + refs.insert(node.node_id, reference); + } + } + for node in &group.nodes { + let AssocConstraintNodeData::ImplicitPoint { + point_type, + curve_id, + .. + } = node.data + else { + continue; + }; + let Some(curve) = refs.get(&curve_id).copied() else { + continue; + }; + let marker = if let Some(segment) = curve.segment_index() { + let point_count = + super::dimension_assoc::source_points(document.get_entity(curve.entity)?).len(); + (match point_type { + implicit_point_type::START => segment, + implicit_point_type::END if point_count > 0 => (segment + 1) % point_count, + _ => continue, + }) as i32 + } else { + match point_type { + implicit_point_type::START => 0, + implicit_point_type::END => 1, + implicit_point_type::CENTER => -3, + _ => continue, + } + }; + refs.insert(node.node_id, SketchRef::point(curve.entity, marker)); + } + + let scope = if owner == document.header.model_space_block_handle { + SketchScope::ModelSpace + } else { + SketchScope::Block(owner) + }; + let mut set = SketchConstraintSet::new(scope); + for node in &group.nodes { + let Some(kind) = constraint_kind(node) else { + continue; + }; + if !constraint_is_active(&node.data) { + continue; + } + let mut targets: Vec = node + .connections + .iter() + .filter_map(|id| refs.get(id).copied()) + .collect(); + if let Some(owner) = constraint_owner(&node.data).and_then(|id| refs.get(&id).copied()) { + if !targets.contains(&owner) { + targets.insert(0, owner); + } + } + if matches!( + kind, + ConstraintKind::PointOnCurve | ConstraintKind::CenterPoint + ) && targets.len() == 2 + && targets[0].entity == targets[1].entity + { + continue; + } + let driving = match &node.data { + AssocConstraintNodeData::Angle { + value_dependency, .. + } + | AssocConstraintNodeData::Distance { + value_dependency, .. + } + | AssocConstraintNodeData::RadiusDiameter { + value_dependency, .. + } => driving_value(document, *value_dependency, parameters), + _ => None, + }; + let expected = match kind { + ConstraintKind::Fixed | ConstraintKind::Radius | ConstraintKind::Diameter => 1, + ConstraintKind::Symmetric => 3, + ConstraintKind::EqualDistance => 4, + _ => 2, + }; + if (matches!(kind, ConstraintKind::Horizontal | ConstraintKind::Vertical) + && !matches!(targets.len(), 1 | 2)) + || (!matches!(kind, ConstraintKind::Horizontal | ConstraintKind::Vertical) + && targets.len() != expected) + { + continue; + } + if matches!( + kind, + ConstraintKind::Distance + | ConstraintKind::DistanceX + | ConstraintKind::DistanceY + | ConstraintKind::Angle + | ConstraintKind::Radius + | ConstraintKind::Diameter + ) && driving.is_none() + { + continue; + } + set.add(kind, targets, driving); + } + (!set.constraints.is_empty()).then_some(set) +} + /// One scope's worth of /// [`Scene::materialize_dwg_native_constraints_for_save`] — see that /// method's doc comment for the save-time contract this implements. @@ -919,15 +1626,19 @@ fn materialize_scope( continue; }; if entity_nodes.geometry_node_id == 0 { - continue; + if entity_nodes.segments.is_empty() { + continue; + } } let dep_handle = allocator.geom_dependency(group_handle, *entity_handle, index as i32 + 1); geometry_dependencies.push(dep_handle); - if let Some(node) = nodes - .iter_mut() - .find(|n| n.node_id == entity_nodes.geometry_node_id) + for node_id in std::iter::once(entity_nodes.geometry_node_id) + .filter(|node_id| *node_id != 0) + .chain(entity_nodes.segments.values().copied()) { - set_geometry_dependency(&mut node.data, dep_handle); + if let Some(node) = nodes.iter_mut().find(|node| node.node_id == node_id) { + set_geometry_dependency(&mut node.data, dep_handle); + } } } @@ -1039,6 +1750,7 @@ impl Scene { /// toggling this off never leaves a stale graph sitting in the file. pub(crate) fn materialize_dwg_native_constraints_for_save(&mut self, enabled: bool) { let mut scopes = Vec::new(); + let mut preserved_networks = Vec::new(); for index in 0..self.sketch_constraints.len() { let set = self.sketch_constraints[index].clone(); let owner = set.scope.owner_handle(&self.document); @@ -1048,6 +1760,12 @@ impl Scene { if !enabled && !has_native_network(&self.document, owner) { continue; } + if native_group_requires_preservation(&self.document, owner) { + if let Some(network) = native_scope_network_handle(&self.document, owner) { + preserved_networks.push(network); + } + continue; + } scopes.push((owner, set)); } if scopes.is_empty() { @@ -1062,7 +1780,7 @@ impl Scene { NETWORK_DICTIONARY_KEY, ); let root_network_handle = self.document.allocate_handle(); - let mut child_networks = Vec::new(); + let mut child_networks = preserved_networks; for (index, (owner, set)) in scopes.into_iter().enumerate() { if let Some(handle) = materialize_scope( &mut self.document, @@ -1119,7 +1837,9 @@ impl Scene { mod tests { use super::super::sketch_constraints::SketchScope; use super::*; - use acadrust::entities::{Arc, Circle, Line}; + use acadrust::entities::{Arc, Circle, Insert, Line, LwPolyline}; + use acadrust::tables::BlockRecord; + use acadrust::types::Vector2; fn line_entity(scene: &mut Scene, start: (f64, f64), end: (f64, f64)) -> Handle { scene.add_entity(EntityType::Line(Line::from_points( @@ -1207,13 +1927,12 @@ mod tests { scene.materialize_dwg_native_constraints_for_save(true); let owner = scene.document.header.model_space_block_handle; - // Sanity before the round trip even happens: root + 2 geometry - // nodes + 2 constraint nodes. + // Root + two geometry nodes + two datums + two constraints. let before = native_group(&scene.document, owner); assert_eq!( before.nodes.len(), - 5, - "expected root + 2 geometry + 2 constraint nodes, got {:#?}", + 7, + "expected root + 2 geometry + 2 datums + 2 constraints, got {:#?}", before.nodes ); @@ -1241,9 +1960,157 @@ mod tests { 2, "{ext}: expected both lines' geometry nodes to survive, got {class_names:?}" ); + let datums: Vec = group + .nodes + .iter() + .filter_map(|node| match node.data { + AssocConstraintNodeData::Parallel { + datum_line_index: Some(datum), + .. + } if matches!( + node.class_name.as_str(), + "AcHorizontalConstraint" | "AcVerticalConstraint" + ) => + { + Some(datum) + } + _ => None, + }) + .collect(); + assert_eq!(datums.len(), 2, "{ext}: both constraints need a datum"); + assert!(datums.iter().all(|datum| group.nodes.iter().any(|node| { + node.node_id == *datum && node.class_name == "AcConstrainedDatumLine" + }))); } } + #[test] + fn native_only_constraints_and_parameters_are_imported() { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let b = line_entity(&mut scene, (0.0, 5.0), (10.0, 5.0)); + scene.named_parameters.set("gap", "5").unwrap(); + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + set.add( + ConstraintKind::Parallel, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); + set.add( + ConstraintKind::DistanceY, + vec![SketchRef::point(a, 0), SketchRef::point(b, 0)], + Some(DrivingValue::Named("gap".to_string())), + ); + + scene.materialize_dwg_native_constraints_for_save(true); + let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) + .expect("save native-only graph"); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes("native_only.dwg", bytes).unwrap(); + restored.load_named_parameters_from_document(); + restored.load_sketch_constraints_from_document(); + + let set = restored + .sketch_constraint_set(SketchScope::ModelSpace) + .expect("native group should become an editable constraint set"); + let kinds: Vec<_> = set + .constraints + .iter() + .map(|constraint| constraint.kind) + .collect(); + assert_eq!( + kinds, + vec![ + ConstraintKind::Horizontal, + ConstraintKind::Parallel, + ConstraintKind::DistanceY, + ] + ); + assert_eq!(restored.named_parameters.resolve("gap"), Ok(5.0)); + assert_eq!( + set.constraints[2].driving_param, + Some(DrivingValue::Named("gap".to_string())) + ); + } + + #[test] + fn point_and_two_point_horizontal_constraints_round_trip_natively() { + let mut scene = Scene::new(); + let mut block = BlockRecord::new("fixture"); + block.handle = scene.document.allocate_handle(); + scene.document.block_records.add(block).unwrap(); + let point = scene.add_entity(EntityType::Point(acadrust::entities::Point::at( + Vector3::new(1.0, 2.0, 0.0), + ))); + let insert = scene.add_entity(EntityType::Insert(Insert::new( + "fixture", + Vector3::new(3.0, 4.0, 0.0), + ))); + let line = line_entity(&mut scene, (5.0, 7.0), (10.0, 9.0)); + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add( + ConstraintKind::Horizontal, + vec![SketchRef::point(point, 0), SketchRef::point(insert, 0)], + None, + ); + set.add( + ConstraintKind::Midpoint, + vec![SketchRef::point(point, 0), SketchRef::whole(line)], + None, + ); + + scene.materialize_dwg_native_constraints_for_save(true); + let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) + .expect("save point graph"); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes("point_constraints.dwg", bytes).unwrap(); + restored.load_sketch_constraints_from_document(); + + let constraints = &restored + .sketch_constraint_set(SketchScope::ModelSpace) + .expect("native point constraints should be imported") + .constraints; + assert_eq!(constraints.len(), 2); + assert_eq!(constraints[0].kind, ConstraintKind::Horizontal); + assert_eq!(constraints[0].refs.len(), 2); + assert_eq!(constraints[1].kind, ConstraintKind::Midpoint); + } + + #[test] + fn polyline_segment_constraints_round_trip_through_the_native_graph() { + let mut scene = Scene::new(); + let polyline = scene.add_entity(EntityType::LwPolyline(LwPolyline::from_points(vec![ + Vector2::new(0.0, 0.0), + Vector2::new(5.0, 2.0), + Vector2::new(10.0, 7.0), + ]))); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![SketchRef::segment(polyline, 1)], + None, + ); + + scene.materialize_dwg_native_constraints_for_save(true); + let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) + .expect("save polyline segment graph"); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes("polyline_segment.dwg", bytes).unwrap(); + restored.load_sketch_constraints_from_document(); + + let set = restored + .sketch_constraint_set(SketchScope::ModelSpace) + .expect("native segment constraint should be imported"); + assert_eq!(set.constraints.len(), 1); + assert_eq!(set.constraints[0].kind, ConstraintKind::Horizontal); + assert_eq!( + set.constraints[0].refs, + vec![SketchRef::segment(polyline, 1)] + ); + } + fn distance_with_named_parameter_scene() -> (Scene, Handle) { let mut scene = Scene::new(); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); @@ -1415,6 +2282,53 @@ mod tests { ); } + #[test] + fn resaving_preserves_a_native_group_with_uneditable_geometry() { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + scene.materialize_dwg_native_constraints_for_save(true); + + let owner = scene.document.header.model_space_block_handle; + let group_handle = native_group_handle(&scene.document, owner); + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), + .. + })) = scene.document.objects.get_mut(&group_handle) + else { + panic!("expected a native constraint group"); + }; + group.nodes.push(AssocConstraintNode { + node_id: 99, + class_name: "AcConstrainedRigidSet".to_string(), + data: AssocConstraintNodeData::RigidSet { + geometry_dependency: Handle::NULL, + geometry_node_id: 99, + reserved: false, + transform: [0.0; 16], + geometry_ids: vec![1], + }, + ..Default::default() + }); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Vertical, vec![SketchRef::whole(a)], None); + scene.materialize_dwg_native_constraints_for_save(true); + + let group = native_group(&scene.document, owner); + assert!(group + .nodes + .iter() + .any(|node| matches!(node.data, AssocConstraintNodeData::RigidSet { .. }))); + assert!(!group + .nodes + .iter() + .any(|node| node.class_name == "AcVerticalConstraint")); + } + #[test] fn an_empty_constraint_set_leaves_no_native_network() { let mut scene = Scene::new(); @@ -1711,16 +2625,8 @@ mod tests { } } - /// Constraint-parity Phase 5: an ellipse isn't a supported geometry - /// node type in `geometry_node` yet (see its own doc comment for why), - /// so a Concentric constraint referencing one degrades to "not in the - /// native graph" — the same "skip, don't error" contract every other - /// unbuildable native-constraint case gets — while the other - /// constraint in the same scope (Fixed, on a plain circle) still - /// persists normally, and the ellipse constraint itself still survives - /// in this app's own XRecord format. #[test] - fn a_constraint_referencing_an_ellipse_is_absent_from_the_native_graph_but_not_from_xrecord() { + fn an_ellipse_constraint_round_trips_in_the_native_graph() { for ext in ["dxf", "dwg"] { let mut scene = Scene::new(); let ellipse = scene.add_entity(EntityType::Ellipse( @@ -1757,8 +2663,12 @@ mod tests { "{ext}: the other constraint should still persist, got {class_names:?}" ); assert!( - !class_names.contains(&"AcConcentricConstraint"), - "{ext}: the ellipse-referencing Concentric constraint has no supported geometry node and must not appear, got {class_names:?}" + class_names.contains(&"AcConcentricConstraint"), + "{ext}: missing ellipse-referencing constraint, got {class_names:?}" + ); + assert!( + class_names.contains(&"AcConstrainedEllipse"), + "{ext}: missing ellipse geometry node, got {class_names:?}" ); let mut reloaded_scene = Scene::new(); diff --git a/src/scene/sketch_constraints.rs b/src/scene/sketch_constraints.rs index cdbb50c9f..661c9ab9a 100644 --- a/src/scene/sketch_constraints.rs +++ b/src/scene/sketch_constraints.rs @@ -27,6 +27,8 @@ pub struct SketchRef { pub marker: Option, } +const POLYLINE_SEGMENT_MARKER_BASE: i32 = -1_000_000; + impl SketchRef { pub fn whole(entity: Handle) -> Self { Self { @@ -51,6 +53,20 @@ impl SketchRef { marker: Some(-3), } } + + /// Select one straight segment of a polyline. + pub fn segment(entity: Handle, index: usize) -> Self { + Self { + entity, + marker: Some(POLYLINE_SEGMENT_MARKER_BASE - index as i32), + } + } + + pub fn segment_index(self) -> Option { + let marker = self.marker?; + (marker <= POLYLINE_SEGMENT_MARKER_BASE) + .then(|| (POLYLINE_SEGMENT_MARKER_BASE - marker) as usize) + } } /// The friendly, user-facing constraint types — the "what button did they diff --git a/src/scene/sketch_persist.rs b/src/scene/sketch_persist.rs index 0d76c66b1..00a6404e7 100644 --- a/src/scene/sketch_persist.rs +++ b/src/scene/sketch_persist.rs @@ -140,26 +140,28 @@ impl Scene { .map(|record| record.handle) .collect(); for owner in owners { - let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { - continue; - }; - // Concatenate every Chunk entry in order, not just the first — - // a set materialized past `MAX_CHUNK_BYTES` spans more than one - // same-code `310` entry (see that constant's doc comment). - let mut bytes = Vec::new(); - for entry in &record.entries { - if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value { - if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES { - bytes.clear(); - break; + if let Some(record) = self.document.xrecord(owner, XRECORD_KEY) { + let mut bytes = Vec::new(); + for entry in &record.entries { + if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value { + if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES { + bytes.clear(); + break; + } + bytes.extend_from_slice(chunk); } - bytes.extend_from_slice(chunk); } - } - if bytes.is_empty() { + if let Some(set) = decode(&bytes) { + self.sketch_constraints.push(set); + } continue; } - if let Some(set) = decode(&bytes) { + + if let Some(set) = super::dwg_native_constraints::native_constraint_set( + &self.document, + owner, + &mut self.named_parameters, + ) { self.sketch_constraints.push(set); } } diff --git a/src/scene/sketch_solve.rs b/src/scene/sketch_solve.rs index 1562a093c..48bb5a530 100644 --- a/src/scene/sketch_solve.rs +++ b/src/scene/sketch_solve.rs @@ -5,12 +5,15 @@ use std::collections::HashMap; use std::rc::Rc; use acadrust::entities::EntityType; -use acadrust::types::Handle; +use acadrust::types::{Handle, Vector3}; use cadkernel_constraints::constraints::angle_distance::L2LAngle; use cadkernel_constraints::constraints::circle_arc::{ ArcLength, C2LDistance, P2CDistance, TangentCircumf, }; +use cadkernel_constraints::constraints::conic::{ + EqualMajorAxesConic, PointOnEllipse, TangentEllipseLine, +}; use cadkernel_constraints::constraints::curve_generic::CurveValue; use cadkernel_constraints::constraints::point_line::{ CenterOfGravity, Difference, Equal, EqualLineLength, MidpointOnLine, P2PDistance, @@ -18,7 +21,8 @@ use cadkernel_constraints::constraints::point_line::{ }; use cadkernel_constraints::constraints::Constraint; use cadkernel_constraints::geo::{ - Arc as GArc, Circle as GCircle, Ellipse as GEllipse, Line as GLine, Point as GPoint, + Arc as GArc, Circle as GCircle, Conic as GConic, Ellipse as GEllipse, Line as GLine, + Point as GPoint, }; use cadkernel_constraints::solvers::dogleg::solve_dl; use cadkernel_constraints::system::System; @@ -31,22 +35,22 @@ use super::{ChangeKind, Scene}; /// One referenced entity's geometry, registered into an `cadkernel_constraints::System`'s /// parameter store. -#[derive(Clone, Copy)] +#[derive(Clone)] enum EntityGeom { + Point(GPoint), Line(GLine), + Polyline { + points: Rc>, + straight: Rc>, + closed: bool, + }, Circle(GCircle), /// Full `geo::Arc` — center/radius (via `.circle`), `start_angle`/ /// `end_angle`, and real `start`/`end` points kept consistent with /// those by the arc-rules `CurveValue` constraints `solve_scope` adds /// for every registered arc. See this module's doc comment. Arc(GArc), - /// Center-and-shape support only — see `register_entity`'s `Ellipse` - /// arm for the acadrust-to-`cadkernel_constraints` parametrization conversion, the - /// ellipse-rules `Difference` constraints `solve_scope` adds for every - /// registered ellipse to keep `focus1` translating rigidly with - /// `center` (this module's doc comment), and that same doc comment for - /// what isn't wired up yet (major/minor axis dimensional constraints, - /// `PointOnEllipse`-based Coincident/Tangent). + /// See `register_entity` for the entity-to-kernel parametrization. Ellipse(GEllipse), } @@ -57,8 +61,12 @@ impl EntityGeom { /// read side). fn point_for_marker(&self, marker: i32) -> Option { match (self, marker) { + (EntityGeom::Point(point), 0) => Some(*point), (EntityGeom::Line(l), 0) => Some(l.p1), (EntityGeom::Line(l), 1) => Some(l.p2), + (EntityGeom::Polyline { points, .. }, marker) if marker >= 0 => { + points.get(marker as usize).copied() + } (EntityGeom::Circle(c), -3) => Some(c.center), (EntityGeom::Arc(a), 0) => Some(a.start), (EntityGeom::Arc(a), 1) => Some(a.end), @@ -67,6 +75,29 @@ impl EntityGeom { _ => None, } } + + fn line_segment(&self, index: usize) -> Option { + let EntityGeom::Polyline { + points, + straight, + closed, + } = self + else { + return None; + }; + if !straight.get(index).copied().unwrap_or(false) { + return None; + } + let p1 = *points.get(index)?; + let p2 = if index + 1 < points.len() { + points[index + 1] + } else if *closed { + *points.first()? + } else { + return None; + }; + Some(GLine { p1, p2 }) + } } /// `Tangent`/`Normal` only ever care about "a whole circle-shaped curve" or @@ -76,15 +107,22 @@ impl EntityGeom { enum CircleOrLine { Circle(GCircle), Line(GLine), + Ellipse(GEllipse), Other, } -fn as_circle_or_line(g: EntityGeom) -> CircleOrLine { +fn as_circle_or_line(g: EntityGeom, reference: SketchRef) -> CircleOrLine { match g { EntityGeom::Circle(c) => CircleOrLine::Circle(c), EntityGeom::Arc(a) => CircleOrLine::Circle(a.circle), EntityGeom::Line(l) => CircleOrLine::Line(l), - EntityGeom::Ellipse(_) => CircleOrLine::Other, + EntityGeom::Ellipse(ellipse) => CircleOrLine::Ellipse(ellipse), + EntityGeom::Point(_) => CircleOrLine::Other, + EntityGeom::Polyline { .. } => reference + .segment_index() + .and_then(|index| g.line_segment(index)) + .map(CircleOrLine::Line) + .unwrap_or(CircleOrLine::Other), } } @@ -103,7 +141,72 @@ fn register_entity( sys: &mut System, handle: Handle, ) -> Option { - match document.get_entity(handle)? { + let entity = document.get_entity(handle)?; + match entity { + EntityType::Point(point) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(point.location.x, false), + sys.add_param(point.location.y, false), + ))), + EntityType::Insert(insert) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(insert.insert_point.x, false), + sys.add_param(insert.insert_point.y, false), + ))), + EntityType::Text(text) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(text.insertion_point.x, false), + sys.add_param(text.insertion_point.y, false), + ))), + EntityType::MText(text) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(text.insertion_point.x, false), + sys.add_param(text.insertion_point.y, false), + ))), + EntityType::AttributeDefinition(attribute) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(attribute.insertion_point.x, false), + sys.add_param(attribute.insertion_point.y, false), + ))), + EntityType::AttributeEntity(attribute) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(attribute.insertion_point.x, false), + sys.add_param(attribute.insertion_point.y, false), + ))), + EntityType::Table(table) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(table.insertion_point.x, false), + sys.add_param(table.insertion_point.y, false), + ))), + EntityType::LwPolyline(polyline) => Some(EntityGeom::Polyline { + points: Rc::new( + super::dimension_assoc::source_points(entity) + .into_iter() + .map(|point| { + GPoint::new(sys.add_param(point.x, false), sys.add_param(point.y, false)) + }) + .collect(), + ), + straight: Rc::new( + polyline + .vertices + .iter() + .map(|vertex| vertex.bulge.abs() <= 1e-9) + .collect(), + ), + closed: polyline.is_closed, + }), + EntityType::Polyline2D(polyline) => Some(EntityGeom::Polyline { + points: Rc::new( + super::dimension_assoc::source_points(entity) + .into_iter() + .map(|point| { + GPoint::new(sys.add_param(point.x, false), sys.add_param(point.y, false)) + }) + .collect(), + ), + straight: Rc::new( + polyline + .vertices + .iter() + .map(|vertex| vertex.bulge.abs() <= 1e-9) + .collect(), + ), + closed: polyline.is_closed(), + }), EntityType::Line(l) => { let p1 = GPoint::new( sys.add_param(l.start.x, false), @@ -202,11 +305,11 @@ fn resolve_ref( cache: &mut HashMap, r: SketchRef, ) -> Option { - if let Some(&geometry) = cache.get(&r.entity) { - return Some(geometry); + if let Some(geometry) = cache.get(&r.entity) { + return Some(geometry.clone()); } let geometry = register_entity(document, sys, r.entity)?; - cache.insert(r.entity, geometry); + cache.insert(r.entity, geometry.clone()); Some(geometry) } @@ -221,6 +324,17 @@ fn is_world_z(normal: acadrust::types::Vector3) -> bool { /// Returns the world-XY plane elevation supported by the current 2D bridge. fn entity_plane_z(entity: &EntityType) -> Option { let z = match entity { + EntityType::Point(point) if is_world_z(point.normal) => point.location.z, + EntityType::Insert(insert) if is_world_z(insert.normal) => insert.insert_point.z, + EntityType::Text(text) if is_world_z(text.normal) => text.insertion_point.z, + EntityType::MText(text) if is_world_z(text.normal) => text.insertion_point.z, + EntityType::AttributeDefinition(attribute) if is_world_z(attribute.normal) => { + attribute.insertion_point.z + } + EntityType::AttributeEntity(attribute) if is_world_z(attribute.normal) => { + attribute.insertion_point.z + } + EntityType::Table(table) if is_world_z(table.normal) => table.insertion_point.z, EntityType::Line(line) if is_world_z(line.normal) && (line.start.z - line.end.z).abs() <= PLANE_EPS => { @@ -330,11 +444,13 @@ fn build_constraint( return Vec::new(); } - let whole_line = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref( - document, sys, cache, r, - )? { - EntityGeom::Line(l) => Some(l), - EntityGeom::Circle(_) | EntityGeom::Arc(_) | EntityGeom::Ellipse(_) => None, + let whole_line = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| { + let geometry = resolve_ref(document, sys, cache, r)?; + match geometry { + EntityGeom::Line(line) => Some(line), + EntityGeom::Polyline { .. } => geometry.line_segment(r.segment_index()?), + _ => None, + } }; let whole_circle = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref( @@ -342,7 +458,10 @@ fn build_constraint( )? { EntityGeom::Circle(circ) => Some(circ), EntityGeom::Arc(a) => Some(a.circle), - EntityGeom::Line(_) | EntityGeom::Ellipse(_) => None, + EntityGeom::Point(_) + | EntityGeom::Line(_) + | EntityGeom::Polyline { .. } + | EntityGeom::Ellipse(_) => None, }; let point_ref = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| { let marker = r.marker?; @@ -371,24 +490,30 @@ fn build_constraint( ConstraintKind::Coincident | ConstraintKind::Concentric | ConstraintKind::CenterPoint => { point_pair_equal(sys, cache, &c.refs) } - ConstraintKind::Horizontal => { - let Some(r) = c.refs.first() else { - return Vec::new(); - }; - let Some(l) = whole_line(sys, cache, *r) else { - return Vec::new(); - }; - vec![Rc::new(Equal::new(l.p1.y, l.p2.y, 1.0))] - } - ConstraintKind::Vertical => { - let Some(r) = c.refs.first() else { - return Vec::new(); - }; - let Some(l) = whole_line(sys, cache, *r) else { - return Vec::new(); - }; - vec![Rc::new(Equal::new(l.p1.x, l.p2.x, 1.0))] - } + ConstraintKind::Horizontal => match c.refs.as_slice() { + [r] => whole_line(sys, cache, *r) + .map(|line| { + vec![Rc::new(Equal::new(line.p1.y, line.p2.y, 1.0)) as Rc] + }) + .unwrap_or_default(), + [a, b] => match (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) { + (Some(a), Some(b)) => vec![Rc::new(Equal::new(a.y, b.y, 1.0))], + _ => Vec::new(), + }, + _ => Vec::new(), + }, + ConstraintKind::Vertical => match c.refs.as_slice() { + [r] => whole_line(sys, cache, *r) + .map(|line| { + vec![Rc::new(Equal::new(line.p1.x, line.p2.x, 1.0)) as Rc] + }) + .unwrap_or_default(), + [a, b] => match (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) { + (Some(a), Some(b)) => vec![Rc::new(Equal::new(a.x, b.x, 1.0))], + _ => Vec::new(), + }, + _ => Vec::new(), + }, ConstraintKind::Parallel => { let [a, b] = c.refs.as_slice() else { return Vec::new(); @@ -422,6 +547,15 @@ fn build_constraint( if let (Some(la), Some(lb)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) { return vec![Rc::new(EqualLineLength::new(lb, la))]; } + if let (Some(EntityGeom::Ellipse(a)), Some(EntityGeom::Ellipse(b))) = ( + resolve_ref(document, sys, cache, *a), + resolve_ref(document, sys, cache, *b), + ) { + return vec![Rc::new(EqualMajorAxesConic::new( + GConic::Ellipse(a), + GConic::Ellipse(b), + ))]; + } let (Some(ca), Some(cb)) = (whole_circle(sys, cache, *a), whole_circle(sys, cache, *b)) else { return Vec::new(); @@ -507,10 +641,15 @@ fn build_constraint( let zero = sys.add_param(0.0, true); vec![Rc::new(P2CDistance::new(a.circle, p, zero))] } - // `PointOnEllipse` (`cadkernel_constraints::constraints::conic`) isn't - // wired up yet — deliberately deferred alongside - // ellipse-tangency, same as this module's doc comment. - EntityGeom::Ellipse(_) => Vec::new(), + EntityGeom::Ellipse(ellipse) => { + vec![Rc::new(PointOnEllipse::new(p, ellipse))] + } + EntityGeom::Polyline { .. } => b + .segment_index() + .and_then(|index| geom.line_segment(index)) + .map(|line| vec![Rc::new(PointOnLine::new(p, line)) as Rc]) + .unwrap_or_default(), + EntityGeom::Point(_) => Vec::new(), } } ConstraintKind::Symmetric => { @@ -534,10 +673,73 @@ fn build_constraint( let Some(r) = c.refs.first() else { return Vec::new(); }; + if r.segment_index().is_some() { + let Some(line) = whole_line(sys, cache, *r) else { + return Vec::new(); + }; + let (x1, y1, x2, y2) = { + let store = sys.store(); + ( + store.get(line.p1.x), + store.get(line.p1.y), + store.get(line.p2.x), + store.get(line.p2.y), + ) + }; + return vec![ + Rc::new(Equal::new(line.p1.x, sys.add_param(x1, true), 1.0)), + Rc::new(Equal::new(line.p1.y, sys.add_param(y1, true), 1.0)), + Rc::new(Equal::new(line.p2.x, sys.add_param(x2, true), 1.0)), + Rc::new(Equal::new(line.p2.y, sys.add_param(y2, true), 1.0)), + ]; + } + if r.marker.is_some() { + let Some(point) = point_ref(sys, cache, *r) else { + return Vec::new(); + }; + let (x, y) = { + let store = sys.store(); + (store.get(point.x), store.get(point.y)) + }; + return vec![ + Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)), + Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)), + ]; + } let Some(geom) = resolve_ref(document, sys, cache, *r) else { return Vec::new(); }; match geom { + EntityGeom::Point(point) => { + let (x, y) = { + let store = sys.store(); + (store.get(point.x), store.get(point.y)) + }; + vec![ + Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)), + Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)), + ] + } + EntityGeom::Polyline { points, .. } => { + let values: Vec<_> = points + .iter() + .map(|point| { + let store = sys.store(); + (point, store.get(point.x), store.get(point.y)) + }) + .collect(); + values + .into_iter() + .flat_map(|(point, x, y)| { + [ + Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)) + as Rc, + Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)) + as Rc, + ] + }) + .collect() + } EntityGeom::Line(l) => { let (x1, y1, x2, y2) = { let store = sys.store(); @@ -724,7 +926,7 @@ fn build_constraint( // for tangency math — normalize both refs down first so the // match below doesn't need every Circle/Arc combination // written out separately. - match (as_circle_or_line(ga), as_circle_or_line(gb)) { + match (as_circle_or_line(ga, *a), as_circle_or_line(gb, *b)) { (CircleOrLine::Circle(c1), CircleOrLine::Circle(c2)) => { let store = sys.store(); let (x1, y1, r1) = ( @@ -765,9 +967,10 @@ fn build_constraint( let zero = sys.add_param(0.0, true); vec![Rc::new(C2LDistance::new(circ, line, zero, ccw, false))] } - // Line-Line tangency has no meaning; either side being an - // Ellipse falls here too (`PointOnEllipse`/tangency isn't - // wired up yet). + (CircleOrLine::Ellipse(ellipse), CircleOrLine::Line(line)) + | (CircleOrLine::Line(line), CircleOrLine::Ellipse(ellipse)) => { + vec![Rc::new(TangentEllipseLine::new(line, ellipse))] + } _ => Vec::new(), } } @@ -788,7 +991,7 @@ fn build_constraint( ) else { return Vec::new(); }; - match (as_circle_or_line(ga), as_circle_or_line(gb)) { + match (as_circle_or_line(ga, *a), as_circle_or_line(gb, *b)) { (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { vec![Rc::new(PointOnLine::new(circ.center, line))] @@ -1031,7 +1234,9 @@ fn solve_scope( let total_free: usize = cache .values() .map(|g| match g { + EntityGeom::Point(_) => 2, EntityGeom::Line(_) => 4, + EntityGeom::Polyline { points, .. } => points.len() * 2, EntityGeom::Circle(_) => 3, // Raw param count (center×2, rad, start×2, end×2, both // angles) — same "raw, not netted against its own @@ -1077,6 +1282,98 @@ fn solve_scope( continue; }; match (entity, geom) { + (EntityType::Point(point), EntityGeom::Point(geometry)) => { + let x = store.get(geometry.x); + let y = store.get(geometry.y); + if (x - point.location.x).abs() > MOVE_EPS + || (y - point.location.y).abs() > MOVE_EPS + { + let mut updated = point.clone(); + updated.location.x = x; + updated.location.y = y; + results.push((handle, EntityType::Point(updated))); + } + } + (EntityType::Insert(insert), EntityGeom::Point(geometry)) => { + let mut updated = insert.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + insert.insert_point.z, + ); + if (next - insert.insert_point).length() > MOVE_EPS { + updated.insert_point = next; + results.push((handle, EntityType::Insert(updated))); + } + } + (EntityType::Text(text), EntityGeom::Point(geometry)) => { + let mut updated = text.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + text.insertion_point.z, + ); + let delta = next - text.insertion_point; + if delta.length() > MOVE_EPS { + updated.insertion_point = next; + if let Some(alignment) = &mut updated.alignment_point { + *alignment = *alignment + delta; + } + results.push((handle, EntityType::Text(updated))); + } + } + (EntityType::MText(text), EntityGeom::Point(geometry)) => { + let mut updated = text.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + text.insertion_point.z, + ); + if (next - text.insertion_point).length() > MOVE_EPS { + updated.insertion_point = next; + results.push((handle, EntityType::MText(updated))); + } + } + (EntityType::AttributeDefinition(attribute), EntityGeom::Point(geometry)) => { + let mut updated = attribute.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + attribute.insertion_point.z, + ); + let delta = next - attribute.insertion_point; + if delta.length() > MOVE_EPS { + updated.insertion_point = next; + updated.alignment_point = updated.alignment_point + delta; + results.push((handle, EntityType::AttributeDefinition(updated))); + } + } + (EntityType::AttributeEntity(attribute), EntityGeom::Point(geometry)) => { + let mut updated = attribute.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + attribute.insertion_point.z, + ); + let delta = next - attribute.insertion_point; + if delta.length() > MOVE_EPS { + updated.insertion_point = next; + updated.alignment_point = updated.alignment_point + delta; + results.push((handle, EntityType::AttributeEntity(updated))); + } + } + (EntityType::Table(table), EntityGeom::Point(geometry)) => { + let mut updated = table.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + table.insertion_point.z, + ); + if (next - table.insertion_point).length() > MOVE_EPS { + updated.insertion_point = next; + results.push((handle, EntityType::Table(updated))); + } + } (EntityType::Line(l), EntityGeom::Line(g)) => { let (x1, y1, x2, y2) = ( store.get(g.p1.x), @@ -1097,6 +1394,42 @@ fn solve_scope( results.push((handle, EntityType::Line(updated))); } } + (EntityType::LwPolyline(polyline), EntityGeom::Polyline { points, .. }) => { + let mut updated = polyline.clone(); + let mut changed = false; + for (vertex, point) in updated.vertices.iter_mut().zip(points.iter()) { + let x = store.get(point.x); + let y = store.get(point.y); + if (x - vertex.location.x).abs() > MOVE_EPS + || (y - vertex.location.y).abs() > MOVE_EPS + { + vertex.location.x = x; + vertex.location.y = y; + changed = true; + } + } + if changed { + results.push((handle, EntityType::LwPolyline(updated))); + } + } + (EntityType::Polyline2D(polyline), EntityGeom::Polyline { points, .. }) => { + let mut updated = polyline.clone(); + let mut changed = false; + for (vertex, point) in updated.vertices.iter_mut().zip(points.iter()) { + let x = store.get(point.x); + let y = store.get(point.y); + if (x - vertex.location.x).abs() > MOVE_EPS + || (y - vertex.location.y).abs() > MOVE_EPS + { + vertex.location.x = x; + vertex.location.y = y; + changed = true; + } + } + if changed { + results.push((handle, EntityType::Polyline2D(updated))); + } + } (EntityType::Circle(c), EntityGeom::Circle(g)) => { let (cx, cy, r) = ( store.get(g.center.x), @@ -1671,11 +2004,9 @@ mod tests { panic!("expected spline"); }; let curve = crate::entities::spline::nurbs3(spline).unwrap(); - let jet = cadkernel::space::CurveJet::from_nurbs( - &curve, - cadkernel::space::SplineEnd::Start, - ) - .unwrap(); + let jet = + cadkernel::space::CurveJet::from_nurbs(&curve, cadkernel::space::SplineEnd::Start) + .unwrap(); assert!((jet.point[0] - 0.0).abs() < 1e-9); assert!((jet.point[1] - 1.0).abs() < 1e-9); assert!(jet.tangent[1].abs() < 1e-9, "jet={jet:?}"); diff --git a/tests/sketch_constraints_solve.rs b/tests/sketch_constraints_solve.rs index 6741759c3..ebb379c3d 100644 --- a/tests/sketch_constraints_solve.rs +++ b/tests/sketch_constraints_solve.rs @@ -127,6 +127,77 @@ fn horizontal_constraint_levels_the_line_when_an_endpoint_moves() { ); } +#[test] +fn horizontal_constraint_levels_a_polyline_segment() { + let mut scene = Scene::new(); + let polyline = scene.add_entity(EntityType::LwPolyline( + acadrust::entities::LwPolyline::from_points(vec![ + acadrust::types::Vector2::new(0.0, 0.0), + acadrust::types::Vector2::new(5.0, 2.0), + acadrust::types::Vector2::new(10.0, 7.0), + ]), + )); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![SketchRef::segment(polyline, 1)], + None, + ); + + scene.bump_entities(&[(polyline, ChangeKind::Modified)]); + let EntityType::LwPolyline(polyline) = scene.document.get_entity(polyline).unwrap() else { + panic!("expected a lightweight polyline"); + }; + assert!( + (polyline.vertices[1].location.y - polyline.vertices[2].location.y).abs() < 1e-6, + "the selected segment should be horizontal" + ); +} + +#[test] +fn horizontal_constraint_does_not_flatten_a_polyline_arc_segment() { + let mut source = acadrust::entities::LwPolyline::new(); + source.add_point_with_bulge(acadrust::types::Vector2::new(0.0, 0.0), 0.5); + source.add_point(acadrust::types::Vector2::new(5.0, 2.0)); + let mut scene = Scene::new(); + let polyline = scene.add_entity(EntityType::LwPolyline(source)); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![SketchRef::segment(polyline, 0)], + None, + ); + + scene.bump_entities(&[(polyline, ChangeKind::Modified)]); + let EntityType::LwPolyline(polyline) = scene.document.get_entity(polyline).unwrap() else { + panic!("expected a lightweight polyline"); + }; + assert_eq!(polyline.vertices[0].location.y, 0.0); + assert_eq!(polyline.vertices[1].location.y, 2.0); + assert_eq!(polyline.vertices[0].bulge, 0.5); +} + +#[test] +fn horizontal_constraint_aligns_two_selected_points() { + let mut scene = Scene::new(); + let a = add_line(&mut scene, 0.0, 1.0, 4.0, 3.0); + let b = add_line(&mut scene, 8.0, 7.0, 12.0, 9.0); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![SketchRef::point(a, 0), SketchRef::point(b, 0)], + None, + ); + + scene.bump_entities(&[(b, ChangeKind::Modified)]); + let (a_start, _) = line_endpoints(&scene, a); + let (b_start, _) = line_endpoints(&scene, b); + assert!((a_start.y - b_start.y).abs() < 1e-6); +} + #[test] fn parallel_constraint_rotates_the_other_line_when_one_moves() { let mut scene = Scene::new(); @@ -655,6 +726,55 @@ fn midpoint_constraint_pulls_a_point_onto_a_lines_midpoint() { ); } +#[test] +fn midpoint_constraint_supports_a_point_entity() { + let mut scene = Scene::new(); + let base = add_line(&mut scene, 0.0, 0.0, 10.0, 6.0); + let point = scene.add_entity(EntityType::Point(acadrust::entities::Point::at( + Vector3::new(20.0, 20.0, 0.0), + ))); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Midpoint, + vec![SketchRef::point(point, 0), SketchRef::whole(base)], + None, + ); + + scene.bump_entities(&[(base, ChangeKind::Modified)]); + let (start, end) = line_endpoints(&scene, base); + let EntityType::Point(point) = scene.document.get_entity(point).unwrap() else { + panic!("expected a point"); + }; + assert!((point.location.x - (start.x + end.x) / 2.0).abs() < 1e-6); + assert!((point.location.y - (start.y + end.y) / 2.0).abs() < 1e-6); +} + +#[test] +fn coincident_constraint_supports_a_block_insertion_point() { + let mut scene = Scene::new(); + let line = add_line(&mut scene, 0.0, 0.0, 10.0, 4.0); + let insert = scene.add_entity(EntityType::Insert(acadrust::entities::Insert::new( + "fixture", + Vector3::new(30.0, 20.0, 0.0), + ))); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Coincident, + vec![SketchRef::point(line, 1), SketchRef::point(insert, 0)], + None, + ); + + scene.bump_entities(&[(line, ChangeKind::Modified)]); + let (_, end) = line_endpoints(&scene, line); + let EntityType::Insert(insert) = scene.document.get_entity(insert).unwrap() else { + panic!("expected a block reference"); + }; + assert!((insert.insert_point.x - end.x).abs() < 1e-6); + assert!((insert.insert_point.y - end.y).abs() < 1e-6); +} + #[test] fn fixed_constraint_holds_an_entity_in_place_despite_a_connected_edit() { let mut scene = Scene::new(); @@ -723,6 +843,92 @@ fn point_on_curve_constraint_pulls_a_point_onto_a_circles_circumference() { ); } +#[test] +fn point_on_curve_constraint_pulls_a_point_onto_an_ellipse() { + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6); + let marker = add_line(&mut scene, 12.0, 8.0, 13.0, 8.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::PointOnCurve, + vec![SketchRef::point(marker, 0), SketchRef::whole(ellipse)], + None, + ); + scene.bump_entities(&[(marker, ChangeKind::Modified)]); + + let (center, major, ratio) = ellipse_geom(&scene, ellipse); + let (point, _) = line_endpoints(&scene, marker); + let a = major.length(); + let b = a * ratio; + let u = major / a; + let v = Vector3::new(-u.y, u.x, 0.0); + let offset = point - center; + let equation = (offset.dot(&u) / a).powi(2) + (offset.dot(&v) / b).powi(2); + assert!( + (equation - 1.0).abs() < 1e-5, + "point should satisfy the solved ellipse equation: {equation}" + ); +} + +#[test] +fn tangent_constraint_solves_an_ellipse_and_line() { + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6); + let line = add_line(&mut scene, -8.0, 8.0, 8.0, 8.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Tangent, + vec![SketchRef::whole(ellipse), SketchRef::whole(line)], + None, + ); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let (center, major, ratio) = ellipse_geom(&scene, ellipse); + let (start, end) = line_endpoints(&scene, line); + let direction = (end - start).normalize(); + let normal = Vector3::new(-direction.y, direction.x, 0.0); + let unit_major = major.normalize(); + let unit_minor = Vector3::new(-unit_major.y, unit_major.x, 0.0); + let a = major.length(); + let b = a * ratio; + let support = + ((a * normal.dot(&unit_major)).powi(2) + (b * normal.dot(&unit_minor)).powi(2)).sqrt(); + let distance = (center - start).dot(&normal).abs(); + assert!( + (distance - support).abs() < 1e-5, + "line should be tangent to the solved ellipse: distance={distance} support={support}" + ); +} + +#[test] +fn equal_constraint_matches_ellipse_major_axes() { + let mut scene = Scene::new(); + let a = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6); + let b = add_ellipse(&mut scene, 20.0, 0.0, (2.0, 0.0), 0.5); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Equal, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); + scene.bump_entities(&[(b, ChangeKind::Modified)]); + + let (_, major_a, _) = ellipse_geom(&scene, a); + let (_, major_b, _) = ellipse_geom(&scene, b); + assert!( + (major_a.length() - major_b.length()).abs() < 1e-5, + "ellipse major radii should match: {} vs {}", + major_a.length(), + major_b.length() + ); +} + #[test] fn equal_distance_constraint_matches_a_second_point_pairs_separation() { let mut scene = Scene::new(); From c13e86b87d8427b88927763f956f125efac5565a Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:00:47 +0900 Subject: [PATCH 06/33] fix(dimension): draw linear and aligned dimensions in their own plane off world Z A linear or aligned dimension whose normal is not world Z was drawn and exploded as if it lay in world XY. The builders read the offset and the extension directions in XY only, so a dimension turned into a working plane such as XZ lost its offset (now along Z): the dimension line fell onto the measured points and the extension lines stuck out along world Y. Move such a dimension into its own OCS before building it and map the result back onto its plane, as arc length and large radial dimensions already do, for both tessellation and EXPLODE. Co-Authored-By: Claude Opus 5 --- src/entities/dimension.rs | 139 +++++++++++++++++++++++++++++ src/modules/draw/modify/explode.rs | 65 ++++++++++++++ 2 files changed, 204 insertions(+) diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index bc3fa9a2b..470022408 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -3431,6 +3431,26 @@ fn tessellate_dimension_inner( return wires; } } + if let Some((local, normal)) = linear_dimension_in_ocs(dim) { + let mut wires = tessellate_dimension_inner( + document, + handle, + &local, + selected, + entity_color, + line_weight_px, + anno_scale, + selected_set, + active_viewport, + bg_color, + None, + world_per_pixel, + ); + for wire in &mut wires { + map_wire_ocs_to_wcs(wire, normal); + } + return wires; + } let name = handle.value().to_string(); // (Baked-block fast path moved up into scene::tessellate_entity so the // recursive call goes through the LOD ladder, not the kernel path.) @@ -5226,6 +5246,50 @@ pub(crate) fn large_radial_dimension_in_ocs( Some((local, normal)) } +/// A linear or aligned dimension in a plane off world Z, moved into its own +/// OCS. Their builders read the offset and extension directions in XY, so +/// they draw it there and the result is mapped back onto the plane. `None` +/// for a dimension in world XY or of another kind. +pub(crate) fn linear_dimension_in_ocs(dimension: &Dimension) -> Option<(Dimension, Vector3)> { + let length = dimension.base().normal.length(); + if !length.is_finite() || length <= 1.0e-12 { + return None; + } + let normal = dimension.base().normal / length; + if (normal - Vector3::UNIT_Z).length() <= 1.0e-12 { + return None; + } + let normal_tuple = (normal.x, normal.y, normal.z); + let to_ocs = |point: Vector3| { + let point = crate::scene::view::transform::wcs_point_to_ocs( + (point.x, point.y, point.z), + normal_tuple, + ); + Vector3::new(point.0, point.1, point.2) + }; + let mut local = dimension.clone(); + match &mut local { + Dimension::Linear(d) => { + d.first_point = to_ocs(d.first_point); + d.second_point = to_ocs(d.second_point); + d.definition_point = to_ocs(d.definition_point); + d.base.definition_point = d.definition_point; + d.base.text_middle_point = to_ocs(d.base.text_middle_point); + d.base.normal = Vector3::UNIT_Z; + } + Dimension::Aligned(d) => { + d.first_point = to_ocs(d.first_point); + d.second_point = to_ocs(d.second_point); + d.definition_point = to_ocs(d.definition_point); + d.base.definition_point = d.definition_point; + d.base.text_middle_point = to_ocs(d.base.text_middle_point); + d.base.normal = Vector3::UNIT_Z; + } + _ => return None, + } + Some((local, normal)) +} + fn map_wire_ocs_to_wcs(wire: &mut WireModel, normal: Vector3) { let normal_tuple = (normal.x, normal.y, normal.z); let map = |x: f64, y: f64, z: f64| { @@ -7687,4 +7751,79 @@ mod linear_transform_tests { ); assert_measures_ten(&dim); } + + /// Every stroke point the dimension draws, with its low residual added. + fn stroke_points(dim: &Dimension) -> Vec { + let document = CadDocument::new(); + let wires = dim.tessellate( + &document, + Handle::new(0x40), + false, + [1.0; 4], + 1.0, + 1.0, + &rustc_hash::FxHashSet::default(), + None, + [0.0, 0.0, 0.0, 1.0], + None, + None, + ); + let mut points = Vec::new(); + for wire in &wires { + for (index, high) in wire.points.iter().enumerate() { + if !high[0].is_finite() { + continue; + } + let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); + points.push(DVec3::new( + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + )); + } + } + points + } + + /// Turned 90° about X into the XZ plane, the dimension has to draw the + /// same strokes turned with it. + fn assert_draws_turned_with_its_plane(flat: Dimension) { + let turn = glam::DQuat::from_axis_angle(DVec3::X, FRAC_PI_2); + let expected: Vec = stroke_points(&flat).into_iter().map(|p| turn * p).collect(); + assert!(!expected.is_empty(), "the dimension draws no strokes"); + let mut turned = flat; + apply_transform( + &mut turned, + &EntityTransform::Rotate { + center: DVec3::ZERO, + axis: DVec3::X, + angle_rad: FRAC_PI_2, + }, + ); + let drawn = stroke_points(&turned); + assert_eq!(drawn.len(), expected.len(), "stroke point count"); + for (index, (got, want)) in drawn.iter().zip(&expected).enumerate() { + assert!( + got.abs_diff_eq(*want, 1e-3), + "point {index}: expected {want:?}, got {got:?}" + ); + } + } + + /// A linear dimension in a working plane off world Z draws in that plane. + /// Its offset used to be read in world XY only, so once it ran along Z the + /// dimension line fell onto the measured points and the extension lines + /// stuck out along world Y. + #[test] + fn a_linear_dimension_off_world_z_draws_in_its_own_plane() { + assert_draws_turned_with_its_plane(horizontal()); + } + + /// An aligned dimension draws through the same builder and plane. + #[test] + fn an_aligned_dimension_off_world_z_draws_in_its_own_plane() { + let mut d = DimensionAligned::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(6.0, 8.0, 0.0)); + d.definition_point = Vector3::new(2.0, 11.0, 0.0); + assert_draws_turned_with_its_plane(Dimension::Aligned(d)); + } } diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index 52606f06e..5243e8af2 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -927,6 +927,19 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { .collect(); } } + if let Some((local, normal)) = crate::entities::dimension::linear_dimension_in_ocs(dim) { + let (x_axis, y_axis) = + crate::scene::view::transform::ocs_axes((normal.x, normal.y, normal.z)); + let plane = WorkingPlane::new( + DVec3::ZERO, + DVec3::new(x_axis.0, x_axis.1, x_axis.2), + DVec3::new(y_axis.0, y_axis.1, y_axis.2), + ); + return explode_dimension(&local, doc) + .into_iter() + .map(|entity| plane.place_entity(entity)) + .collect(); + } let base = dim.base(); let met = dim_metrics(dim, doc); @@ -1659,6 +1672,58 @@ mod tests { assert_eq!(solids, 2, "expected 2 filled (SOLID) arrowheads, got {solids}"); } + /// The line segments EXPLODE turns the dimension into. + fn exploded_segments(dim: &Dimension, doc: &CadDocument) -> Vec<(DVec3, DVec3)> { + let point = |v: Vector3| DVec3::new(v.x, v.y, v.z); + explode_dimension(dim, doc) + .into_iter() + .filter_map(|entity| match entity { + EntityType::Line(line) => Some((point(line.start), point(line.end))), + _ => None, + }) + .collect() + } + + /// EXPLODE of a linear dimension in a working plane off world Z leaves + /// its lines in that plane. Turned 90° about X, the dimension has to + /// explode into the same lines turned with it; its offset, now along Z, + /// used to be read in world XY only. + #[test] + fn exploding_a_linear_dimension_off_world_z_keeps_its_plane() { + use crate::command::EntityTransform; + let doc = CadDocument::new(); + let mut d = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); + d.definition_point = Vector3::new(0.0, 5.0, 0.0); + let flat = Dimension::Linear(d); + let turn = glam::DQuat::from_axis_angle(DVec3::X, std::f64::consts::FRAC_PI_2); + let expected: Vec<(DVec3, DVec3)> = exploded_segments(&flat, &doc) + .into_iter() + .map(|(a, b)| (turn * a, turn * b)) + .collect(); + assert!(!expected.is_empty(), "the dimension explodes into no lines"); + + let mut turned = EntityType::Dimension(flat); + crate::scene::view::dispatch::apply_transform( + &mut turned, + &EntityTransform::Rotate { + center: DVec3::ZERO, + axis: DVec3::X, + angle_rad: std::f64::consts::FRAC_PI_2, + }, + ); + let EntityType::Dimension(turned) = turned else { + panic!("dimension expected"); + }; + let lines = exploded_segments(&turned, &doc); + assert_eq!(lines.len(), expected.len(), "line count"); + for (index, (got, want)) in lines.iter().zip(&expected).enumerate() { + assert!( + got.0.abs_diff_eq(want.0, 1e-9) && got.1.abs_diff_eq(want.1, 1e-9), + "line {index}: expected {want:?}, got {got:?}" + ); + } + } + // An angular dimension must bake its swept ARC (not just two rays), else a // saved+reloaded angular dim collapses to a V. The block should carry the // two extension lines plus many arc chords. From f85b23bf74ab63a92aef4ba6cea1a70021f6a942 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Mon, 14 Sep 2026 17:07:56 -0500 Subject: [PATCH 07/33] Enumerate installed Windows printers in the plot dialog Populate the plot-dialog printer list from the Windows spooler cache. Validate record counts and UTF-16 name bounds before reading the buffer. Refs #1261 --- Cargo.toml | 2 +- src/io/print_to_printer.rs | 138 ++++++++++++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1467496a5..78b77d9bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,7 @@ fluent-syntax = "0.12" naga = { version = "27", features = ["wgsl-in"] } [target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation"] } +windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing"] } [target.'cfg(target_os = "linux")'.dependencies] ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] } diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 6f4d3a680..d0ca2ad7e 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -89,8 +89,7 @@ pub fn print_existing_pdf(_path: &std::path::Path, _opts: &PrintOptions) -> Resu } /// Enumerate installed printers. Linux/macOS query CUPS via `lpstat -e`; -/// Windows returns an empty list (the "printto" dispatch targets a named -/// printer directly and the system default is always available). +/// Windows queries the spooler's cached local and connected printer list. #[cfg(not(target_arch = "wasm32"))] pub fn list_printers() -> Vec { #[cfg(not(target_os = "windows"))] @@ -111,7 +110,140 @@ pub fn list_printers() -> Vec { } #[cfg(target_os = "windows")] { - Vec::new() + windows_printers().unwrap_or_else(|error| { + eprintln!("Could not enumerate printers: {error}"); + Vec::new() + }) + } +} + +#[cfg(target_os = "windows")] +fn windows_printers() -> std::io::Result> { + use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER}; + use windows_sys::Win32::Graphics::Printing::{ + EnumPrintersW, PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, + }; + + let mut bytes_needed = 0; + let mut printer_count = 0; + let mut buffer = Vec::::new(); + // The printer list can grow between the size query and the data query. + for _ in 0..4 { + let buffer_bytes = u32::try_from(std::mem::size_of_val(buffer.as_slice())) + .map_err(|_| std::io::Error::from(std::io::ErrorKind::OutOfMemory))?; + let data = if buffer.is_empty() { + std::ptr::null_mut() + } else { + buffer.as_mut_ptr().cast::() + }; + // SAFETY: data is null for the size query, otherwise it points to an + // aligned, writable allocation of buffer_bytes bytes. + let success = unsafe { + EnumPrintersW( + PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, + std::ptr::null(), + 4, + data, + buffer_bytes, + &mut bytes_needed, + &mut printer_count, + ) + }; + if success != 0 { + return windows_printer_names(&buffer, printer_count as usize); + } + let error = unsafe { GetLastError() }; + if error != ERROR_INSUFFICIENT_BUFFER || bytes_needed <= buffer_bytes { + return Err(std::io::Error::from_raw_os_error(error as i32)); + } + buffer.resize( + (bytes_needed as usize).div_ceil(std::mem::size_of::()), + 0, + ); + } + Err(std::io::Error::from_raw_os_error( + ERROR_INSUFFICIENT_BUFFER as i32, + )) +} + +#[cfg(target_os = "windows")] +fn windows_printer_names(buffer: &[usize], printer_count: usize) -> std::io::Result> { + use std::io::{Error, ErrorKind}; + use windows_sys::Win32::Graphics::Printing::PRINTER_INFO_4W; + + const { + assert!(std::mem::align_of::() >= std::mem::align_of::()); + } + let buffer_bytes = std::mem::size_of_val(buffer); + if printer_count > buffer_bytes / std::mem::size_of::() { + return Err(Error::from(ErrorKind::InvalidData)); + } + // SAFETY: the initialized buffer is aligned and large enough for these + // records. Raw pointer fields are validated before reading any names. + let records = unsafe { + std::slice::from_raw_parts(buffer.as_ptr().cast::(), printer_count) + }; + let mut names = Vec::with_capacity(printer_count); + for record in records { + if record.pPrinterName.is_null() { + continue; + } + let offset = (record.pPrinterName as usize) + .checked_sub(buffer.as_ptr() as usize) + .filter(|offset| *offset < buffer_bytes && offset % 2 == 0) + .ok_or(Error::from(ErrorKind::InvalidData))?; + // SAFETY: offset is UTF-16 aligned and the slice ends within buffer. + let utf16 = unsafe { + std::slice::from_raw_parts( + buffer.as_ptr().cast::().add(offset).cast::(), + (buffer_bytes - offset) / 2, + ) + }; + let length = utf16 + .iter() + .position(|&unit| unit == 0) + .ok_or(Error::from(ErrorKind::InvalidData))?; + if length != 0 { + names.push(String::from_utf16_lossy(&utf16[..length])); + } + } + names.sort(); + names.dedup(); + Ok(names) +} + +#[cfg(all(test, target_os = "windows"))] +mod printer_buffer_tests { + use super::windows_printer_names; + use windows_sys::Win32::Graphics::Printing::PRINTER_INFO_4W; + + #[test] + fn printer_names_stay_within_the_returned_buffer() { + assert!(windows_printer_names(&[], 0).unwrap().is_empty()); + assert!(windows_printer_names(&[], 1).is_err()); + let mut buffer = vec![0usize; 16]; + let name_offset = std::mem::size_of::(); + let name: Vec = "Printer \u{03b1}\0".encode_utf16().collect(); + // SAFETY: the aligned allocation holds the record and UTF-16 name. + unsafe { + let record = buffer.as_mut_ptr().cast::(); + let name_ptr = buffer + .as_mut_ptr() + .cast::() + .add(name_offset) + .cast::(); + std::ptr::copy_nonoverlapping(name.as_ptr(), name_ptr, name.len()); + (*record).pPrinterName = name_ptr; + assert_eq!( + windows_printer_names(&buffer, 1).unwrap(), + ["Printer \u{03b1}"] + ); + (*record).pPrinterName = buffer.as_mut_ptr().cast::().wrapping_sub(1); + assert!(windows_printer_names(&buffer, 1).is_err()); + (*record).pPrinterName = name_ptr; + } + buffer[name_offset / std::mem::size_of::()..].fill(usize::MAX); + assert!(windows_printer_names(&buffer, 1).is_err()); } } From c93cc175928017159b8e58b04b0911f69ed9880a Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Mon, 14 Sep 2026 17:07:57 -0500 Subject: [PATCH 08/33] Include bitmap images in PDF plots, preview, and printing Include decoded IMAGE/OLE content in the shared PDF plotting path, with clipping, transforms, transparency, draw order, and resource reuse. Use named page content for export and printing. Fixes #1266 --- Cargo.lock | 1 + Cargo.toml | 2 +- src/app/update/file.rs | 451 +- src/io/pdf_export.rs | 455 +- src/io/print_to_printer.rs | 54 +- src/scene/mod.rs | 51 +- src/scene/project.rs | 59 +- tests/fixtures/embedded-image-print.dxf | 7522 +++++++++++++++++++++++ tests/fixtures/embedded-image-print.md | 8 + tests/pdf_export_images_check.rs | 512 ++ tests/pdf_export_text_check.rs | 61 +- 11 files changed, 8598 insertions(+), 578 deletions(-) create mode 100644 tests/fixtures/embedded-image-print.dxf create mode 100644 tests/fixtures/embedded-image-print.md create mode 100644 tests/pdf_export_images_check.rs diff --git a/Cargo.lock b/Cargo.lock index 49635a72c..c5c129bcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4601,6 +4601,7 @@ dependencies = [ "base64", "flate2", "getrandom 0.3.4", + "image", "lopdf", "serde", "serde_derive", diff --git a/Cargo.toml b/Cargo.toml index 1467496a5..f7d03a426 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,7 +85,7 @@ meshopt = "0.6.2" i18n-embed = { version = "0.16", features = ["desktop-requester"] } rayon = "1" open = "5" -printpdf = { version = "0.9.1", default-features = false } +printpdf = { version = "0.9.1", default-features = false, features = ["images"] } ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "win-system-proxy"] } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 1bad3d3ce..c6e699748 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -92,33 +92,7 @@ fn native_paths_match(left: &std::path::Path, right: &std::path::Path) -> bool { } } -type LayoutPlotParams = ( - std::sync::Arc>, - Vec, - Vec, - crate::io::pdf_export::PlotGroupSplits, - f64, - f64, - f64, - f64, - i32, - f32, - Option<(f32, f32, f32, f32)>, -); - -type ClippedPlotParams = ( - Vec, - Vec, - Vec, - crate::io::pdf_export::PlotGroupSplits, - f64, - f64, - f64, - f64, - i32, - f32, - Option<(f32, f32, f32, f32)>, -); +use crate::io::pdf_export::{PdfPageInput, PlotContent}; fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) { use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; @@ -149,11 +123,8 @@ fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f } } -fn plot_content_extents( - wires: &[crate::io::pdf_export::PlotWire], - hatches: &[crate::scene::model::hatch_model::HatchModel], - wipeouts: &[crate::scene::model::hatch_model::HatchModel], -) -> Option<(f64, f64, f64, f64)> { +fn plot_content_extents(content: &PlotContent) -> Option<(f64, f64, f64, f64)> { + let PlotContent { wires, hatches, wipeouts, images, .. } = content; let mut bounds = ( f64::INFINITY, f64::INFINITY, @@ -168,7 +139,7 @@ fn plot_content_extents( bounds.3 = bounds.3.max(y); } }; - for wire in wires { + for wire in wires.iter().filter(|wire| wire.name != "paper_printable_area") { let [x0, y0, x1, y1] = wire.aabb; include(x0 as f64, y0 as f64); include(x1 as f64, y1 as f64); @@ -178,6 +149,29 @@ fn plot_content_extents( include(hatch.world_origin[0] + x as f64, hatch.world_origin[1] + y as f64); } } + for plot in images { + let mut image_bounds = [f64::INFINITY, f64::INFINITY, + f64::NEG_INFINITY, f64::NEG_INFINITY]; + for vertex in &plot.image.verts { + for axis in 0..2 { + let value = vertex.pos[axis] as f64 + vertex.pos_low[axis] as f64; + image_bounds[axis] = image_bounds[axis].min(value); + image_bounds[axis + 2] = image_bounds[axis + 2].max(value); + } + } + for clip in &plot.clips { + for axis in 0..2 { + let min = clip.iter().map(|p| p[axis]).fold(f64::INFINITY, f64::min); + let max = clip.iter().map(|p| p[axis]).fold(f64::NEG_INFINITY, f64::max); + image_bounds[axis] = image_bounds[axis].max(min); + image_bounds[axis + 2] = image_bounds[axis + 2].min(max); + } + } + if image_bounds[0] < image_bounds[2] && image_bounds[1] < image_bounds[3] { + include(image_bounds[0], image_bounds[1]); + include(image_bounds[2], image_bounds[3]); + } + } (bounds.0.is_finite() && bounds.1.is_finite() && bounds.2.is_finite() @@ -191,12 +185,7 @@ fn plot_scene_content( scene: &crate::scene::Scene, paper_space_last: bool, render_mode_override: Option, -) -> ( - std::sync::Arc>, - Vec, - Vec, - crate::io::pdf_export::PlotGroupSplits, -) { +) -> PlotContent { let (mut paper_wires, mut model_wires) = scene.plot_wire_groups(render_mode_override); let plot_viewport_borders = scene .effective_plot_settings() @@ -230,20 +219,23 @@ fn plot_scene_content( let model_wires = with_depth(model_wires); let paper_hatches = scene.paper_plot_hatches().as_ref().clone(); let paper_wipeouts = scene.paper_plot_wipeouts().as_ref().clone(); + let paper_images = scene.paper_plot_images(); if scene.current_layout == "Model" { let splits = crate::io::pdf_export::PlotGroupSplits { wires: paper_wires.len(), hatches: paper_hatches.len(), wipeouts: paper_wipeouts.len(), + images: paper_images.len(), + }; + return PlotContent { + wires: std::sync::Arc::new(paper_wires), + hatches: paper_hatches, + wipeouts: paper_wipeouts, + images: paper_images, + group_splits: splits, }; - return ( - std::sync::Arc::new(paper_wires), - paper_hatches, - paper_wipeouts, - splits, - ); } - let (mut model_pattern_wires, model_hatches, model_wipeouts) = + let (mut model_pattern_wires, model_hatches, model_wipeouts, model_images) = scene.viewport_plot_fills(); model_pattern_wires.retain(|(wire, _)| wire.plot_visible); let model_pattern_wires = model_pattern_wires @@ -251,36 +243,42 @@ fn plot_scene_content( .map(|(wire, draw_depth)| crate::io::pdf_export::PlotWire { wire, draw_depth }) .collect::>(); - let (wires, hatches, wipeouts, splits) = if paper_space_last { + let (wires, hatches, wipeouts, images, splits) = if paper_space_last { let splits = crate::io::pdf_export::PlotGroupSplits { wires: model_wires.len() + model_pattern_wires.len(), hatches: model_hatches.len(), wipeouts: model_wipeouts.len(), + images: model_images.len(), }; let mut wires = model_wires; let mut hatches = model_hatches; let mut wipeouts = model_wipeouts; + let mut images = model_images; wires.extend(model_pattern_wires); wires.extend(paper_wires); hatches.extend(paper_hatches); wipeouts.extend(paper_wipeouts); - (wires, hatches, wipeouts, splits) + images.extend(paper_images); + (wires, hatches, wipeouts, images, splits) } else { let splits = crate::io::pdf_export::PlotGroupSplits { wires: paper_wires.len(), hatches: paper_hatches.len(), wipeouts: paper_wipeouts.len(), + images: paper_images.len(), }; let mut wires = paper_wires; let mut hatches = paper_hatches; let mut wipeouts = paper_wipeouts; + let mut images = paper_images; wires.extend(model_wires); wires.extend(model_pattern_wires); hatches.extend(model_hatches); wipeouts.extend(model_wipeouts); - (wires, hatches, wipeouts, splits) + images.extend(model_images); + (wires, hatches, wipeouts, images, splits) }; - (std::sync::Arc::new(wires), hatches, wipeouts, splits) + PlotContent { wires: std::sync::Arc::new(wires), hatches, wipeouts, images, group_splits: splits } } impl OpenCADStudio { @@ -2986,32 +2984,16 @@ pub(super) fn on_open_file(&mut self) -> Task { self.load_plotsettings_into_dialog(&settings); } } - let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = - self.direct_plot_params() + let Some(page) = + self.direct_plot_page() else { self.command_line .push_error(crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref()); return Task::none(); }; - let plot_style = self.dialog_plot_style(&self.plot_dialog); - let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits); let worker_path = path.clone(); let work = move || { - crate::io::pdf_export::export_pdf( - &wires, - &hatches, - &wipeouts, - page_w, - page_h, - ox, - oy, - rotation, - scale, - clip, - &worker_path, - plot_style.as_ref(), - render_options, - ) + crate::io::pdf_export::export_pdf(&page, &worker_path) .map(|_| crate::tf!("Exported: {}", worker_path.display()).into_owned()) .map_err(|e| crate::tf!("Export failed: {e}").into_owned()) }; @@ -3029,33 +3011,17 @@ pub(super) fn on_open_file(&mut self) -> Task { "Extents" => self.extents_plot_job(), _ => self.window_plot_job(), }; - let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = + let Some(page) = job else { self.command_line .push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref()); return Task::none(); }; - let plot_style = self.dialog_plot_style(&self.plot_dialog); - let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits); let worker_path = path.clone(); self.close_active_modal(); let work = move || { - crate::io::pdf_export::export_pdf( - &wires, - &hatches, - &wipeouts, - page_w, - page_h, - ox, - oy, - rotation, - scale, - clip, - &worker_path, - plot_style.as_ref(), - render_options, - ) + crate::io::pdf_export::export_pdf(&page, &worker_path) .map(|_| {crate::tf!( "Plotted window to {}", worker_path @@ -3192,8 +3158,7 @@ pub(super) fn on_open_file(&mut self) -> Task { dialog.style_name ).into_owned()); } - let plot_style = self.dialog_plot_style(&dialog); - let params = match dialog.area.as_str() { + let area_page = match dialog.area.as_str() { "Display" => self.display_plot_job(), "Extents" => self.extents_plot_job(), "Limits" => self.limits_plot_job(), @@ -3203,62 +3168,12 @@ pub(super) fn on_open_file(&mut self) -> Task { } _ => None, }; - let ( - wires, - hatches, - wipeouts, - group_splits, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - ) = if dialog.area == "Layout" { - self.layout_plot_params_for("Layout") + let page = if dialog.area == "Layout" { + self.layout_plot_page_for("Layout") } else { - let ( - wires, - hatches, - wipeouts, - group_splits, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - ) = params.ok_or_else(|| crate::tf!("Layout '{name}' plot area is empty.").into_owned())?; - ( - std::sync::Arc::new(wires), - hatches, - wipeouts, - group_splits, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - ) + area_page.ok_or_else(|| crate::tf!("Layout '{name}' plot area is empty.").into_owned())? }; - pages.push(crate::io::pdf_export::PdfPageInput { - wires, - hatches, - wipeouts, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - options: Self::pdf_plot_options(&dialog, group_splits), - plot_style, - }); + pages.push(page); } Ok(pages) })(); @@ -3348,7 +3263,7 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::none(); } }; - let options = self.plot_print_options(&dialog, Default::default()); + let options = self.plot_print_options(&dialog); let temp_path = crate::io::print_to_printer::temp_pdf_path("print_all"); self.save_config(); self.close_active_modal(); @@ -3402,42 +3317,31 @@ pub(super) fn on_open_file(&mut self) -> Task { } } - /// Build the render inputs and page geometry for a full-layout plot: wires - /// plus hatch / wipeout fills, the effective (rotation-swapped) sheet size, - /// draw-origin offset, rotation, unit scale, and optional clip. Shared by - /// PDF export, preview, and printer output so all three render identically. - pub(super) fn layout_plot_params(&self) -> LayoutPlotParams { - self.layout_plot_params_for(&self.plot_dialog.area) + /// Assemble a layout page for PDF export, preview, or printing. + pub(super) fn layout_plot_page(&self) -> PdfPageInput { + self.layout_plot_page_for(&self.plot_dialog.area) } - fn layout_plot_params_for(&self, plot_area: &str) -> LayoutPlotParams { + fn layout_plot_page_for(&self, plot_area: &str) -> PdfPageInput { let i = self.active_tab; let scene = &self.tabs[i].scene; let paper_space = scene.current_layout != "Model"; let selected_sheet = plot_dialog_sheet_mm(&self.plot_dialog); - let (source_wires, hatches, wipeouts, mut group_splits) = - plot_scene_content( + let mut content = plot_scene_content( scene, self.plot_dialog.paperspace_last, plot_render_mode_override(&self.plot_dialog), ); // The printable-area rectangle is an on-screen guide, not drawing // content. It used to leak into every paper-space PDF/preview/print. - let wires = if paper_space { - group_splits.wires = source_wires[..group_splits.wires.min(source_wires.len())] + if paper_space { + content.group_splits.wires = content.wires[..content.group_splits.wires.min(content.wires.len())] .iter() .filter(|wire| wire.name != "paper_printable_area") .count(); - std::sync::Arc::new( - source_wires - .iter() - .filter(|wire| wire.name != "paper_printable_area") - .cloned() - .collect(), - ) - } else { - source_wires - }; + std::sync::Arc::make_mut(&mut content.wires) + .retain(|wire| wire.name != "paper_printable_area"); + } if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() { // Paper geometry is stored in layout units; PDF pages use mm. // Scale both geometry and offsets by the same unit conversion. @@ -3470,7 +3374,7 @@ pub(super) fn on_open_file(&mut self) -> Task { let (scale, offset_x, offset_y) = if plot_extents { let (min_x, min_y, max_x, max_y) = - plot_content_extents(&wires, &hatches, &wipeouts) + plot_content_extents(&content) .unwrap_or((x0, y0, x1, y1)); let content_w = (max_x - min_x).max(1e-9); let content_h = (max_y - min_y).max(1e-9); @@ -3556,19 +3460,18 @@ pub(super) fn on_open_file(&mut self) -> Task { } else { None }; - return ( - wires, - hatches, - wipeouts, - group_splits, - page_w, - page_h, + return PdfPageInput { + content, + paper_w: page_w, + paper_h: page_h, offset_x, offset_y, - rotation, - scale as f32, + rotation_deg: rotation, + scale: scale as f32, clip, - ); + options: Self::pdf_plot_options(&self.plot_dialog), + plot_style: self.dialog_plot_style(&self.plot_dialog), + }; } // Model space keeps its established extents + 5% margin behaviour. @@ -3588,65 +3491,50 @@ pub(super) fn on_open_file(&mut self) -> Task { } else { (297.0, 210.0, 0.0, 0.0) }; - ( - wires, - hatches, - wipeouts, - group_splits, - page_w, - page_h, + PdfPageInput { + content, + paper_w: page_w, + paper_h: page_h, offset_x, offset_y, - 0, - 1.0, - None, - ) + rotation_deg: 0, + scale: 1.0, + clip: None, + options: Self::pdf_plot_options(&self.plot_dialog), + plot_style: self.dialog_plot_style(&self.plot_dialog), + } } /// Build direct PDF/printer output using the physical layout sheet in /// paper space and the selected ISO sheet/scale around Model extents. - fn direct_plot_params(&self) -> Option { + fn direct_plot_page(&self) -> Option { if self.tabs[self.active_tab].scene.current_layout != "Model" { - return Some(self.layout_plot_params_for("Layout")); + return Some(self.layout_plot_page_for("Layout")); } - let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) = - self.extents_plot_job()?; - if wires.is_empty() && hatches.is_empty() && wipeouts.is_empty() { + let page = self.extents_plot_job()?; + let content = &page.content; + if content.wires.is_empty() && content.hatches.is_empty() + && content.wipeouts.is_empty() && content.images.is_empty() + { return None; } - Some(( - std::sync::Arc::new(wires), - hatches, - wipeouts, - group_splits, - page_w, - page_h, - ox, - oy, - rotation, - scale, - clip, - )) + Some(page) } pub(super) fn on_print_to_printer(&mut self) -> Task { - let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = - self.direct_plot_params() + let Some(page) = + self.direct_plot_page() else { self.command_line .push_error(crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref()); return Task::none(); }; - let plot_style = self.dialog_plot_style(&self.plot_dialog); - let options = self.plot_print_options(&self.plot_dialog, group_splits); + let options = self.plot_print_options(&self.plot_dialog); self.command_line.push_info(crate::t!("Sending to system printer…").as_ref()); background_task( move || { iced::futures::executor::block_on( - crate::io::print_to_printer::print_wires_with( - wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, - plot_style, options, - )) + crate::io::print_to_printer::print_wires_with(page, options)) }, Message::PrintResult, ) @@ -4537,7 +4425,6 @@ pub(super) fn on_open_file(&mut self) -> Task { self.active_modal = None; self.reset_modal_geometry(); - let plot_style = self.dialog_plot_style(&d); // Extents, Window and Display use one plot path in both spaces. Only // Paper-space Layout is special: it uses the physical sheet bounds. if d.area != "Layout" { @@ -4551,19 +4438,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } _ => None, }; - let Some(( - w_wires, - w_hatches, - w_wipeouts, - wgroup_splits, - sw, - sh, - wox, - woy, - wrotation, - wscale, - wclip, - )) = job + let Some(page) = job else { self.command_line .push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref()); @@ -4574,13 +4449,8 @@ pub(super) fn on_open_file(&mut self) -> Task { // whatever the output target is. if preview { let tmp = crate::io::print_to_printer::temp_pdf_path("preview"); - let render_options = Self::pdf_plot_options(&d, wgroup_splits); let work = move || { - crate::io::pdf_export::export_pdf( - &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp, - plot_style.as_ref(), - render_options, - ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) + crate::io::pdf_export::export_pdf(&page, &tmp).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) .map(|_| crate::t!("Opened plot preview.").into_owned()).map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) }; return self.run_plot_work(d.background, true, work); @@ -4590,42 +4460,21 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::done(Message::PlotWindowExport); } let tmp = crate::io::print_to_printer::temp_pdf_path("print"); - let render_options = Self::pdf_plot_options(&d, wgroup_splits); - let opts = self.plot_print_options(&d, wgroup_splits); + let opts = self.plot_print_options(&d); let work = move || { - crate::io::pdf_export::export_pdf( - &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp, - plot_style.as_ref(), - render_options, - ).and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)) + crate::io::pdf_export::export_pdf(&page, &tmp).and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)) .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) .map_err(|e| crate::tf!("Print failed: {e}").into_owned()) }; return self.run_plot_work(true, false, work); } - let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) = - self.layout_plot_params(); + let page = self.layout_plot_page(); if preview { let tmp = crate::io::print_to_printer::temp_pdf_path("preview"); - let render_options = Self::pdf_plot_options(&d, group_splits); let work = move || { - crate::io::pdf_export::export_pdf( - &wires, - &hatches, - &wipeouts, - page_w, - page_h, - ox, - oy, - rotation, - scale, - clip, - &tmp, - plot_style.as_ref(), - render_options, - ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) + crate::io::pdf_export::export_pdf(&page, &tmp).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) .map(|_| crate::t!("Opened plot preview.").into_owned()).map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) }; return self.run_plot_work(d.background, true, work); @@ -4636,14 +4485,11 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::done(Message::PlotExport); } - let opts = self.plot_print_options(&d, group_splits); + let opts = self.plot_print_options(&d); self.command_line.push_info(crate::t!("Sending to system printer…").as_ref()); let work = move || { iced::futures::executor::block_on( - crate::io::print_to_printer::print_wires_with( - wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, - plot_style, opts, - )) + crate::io::print_to_printer::print_wires_with(page, opts)) .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) .map_err(|error| crate::tf!("Print failed: {error}").into_owned()) }; @@ -4655,19 +4501,16 @@ pub(super) fn on_open_file(&mut self) -> Task { fn plot_print_options( &self, d: &crate::ui::window::plot::PlotDialogState, - group_splits: crate::io::pdf_export::PlotGroupSplits, ) -> crate::io::print_to_printer::PrintOptions { crate::io::print_to_printer::PrintOptions { printer: d.printer.clone(), copies: d.copies.trim().parse::().unwrap_or(1).max(1), quality: Some(d.quality.clone()), - render: Self::pdf_plot_options(d, group_splits), } } fn pdf_plot_options( d: &crate::ui::window::plot::PlotDialogState, - group_splits: crate::io::pdf_export::PlotGroupSplits, ) -> crate::io::pdf_export::PdfPlotOptions { crate::io::pdf_export::PdfPlotOptions { object_lineweights: d.lineweights, @@ -4675,7 +4518,6 @@ pub(super) fn on_open_file(&mut self) -> Task { transparency: d.transparency, stamp: d.stamp, merge_lines: d.merge_lines, - group_splits, } } @@ -4692,22 +4534,22 @@ pub(super) fn on_open_file(&mut self) -> Task { .cloned() } - fn window_plot_job(&self) -> Option { + fn window_plot_job(&self) -> Option { self.area_plot_job(self.plot_window?) } - fn display_plot_job(&self) -> Option { + fn display_plot_job(&self) -> Option { self.area_plot_job(self.display_plot_window()?) } - fn limits_plot_job(&self) -> Option { + fn limits_plot_job(&self) -> Option { let (min, max) = self.tabs[self.active_tab] .scene .current_drawing_limits()?; self.area_plot_job((min.x, min.y, max.x, max.y)) } - fn named_view_plot_job(&self, name: &str) -> Option { + fn named_view_plot_job(&self, name: &str) -> Option { let view = self.tabs[self.active_tab] .scene .document @@ -4728,7 +4570,7 @@ pub(super) fn on_open_file(&mut self) -> Task { )) } - fn extents_plot_job(&self) -> Option { + fn extents_plot_job(&self) -> Option { let scene = &self.tabs[self.active_tab].scene; if scene.current_layout == "Model" { let (min, max) = scene.model_space_extents()?; @@ -4739,21 +4581,12 @@ pub(super) fn on_open_file(&mut self) -> Task { max.y as f64, )); } - let (wires, hatches, wipeouts, _) = - plot_scene_content( - scene, - self.plot_dialog.paperspace_last, - plot_render_mode_override(&self.plot_dialog), - ); - let extents = plot_content_extents( - &wires - .iter() - .filter(|wire| wire.name != "paper_printable_area") - .cloned() - .collect::>(), - &hatches, - &wipeouts, - )?; + let content = plot_scene_content( + scene, + self.plot_dialog.paperspace_last, + plot_render_mode_override(&self.plot_dialog), + ); + let extents = plot_content_extents(&content)?; self.area_plot_job(extents) } @@ -4815,7 +4648,7 @@ pub(super) fn on_open_file(&mut self) -> Task { /// Render a selected rectangle through one shared Model/Paper path. The /// window may lie partly or wholly outside a paper sheet. - fn area_plot_job(&self, window: (f64, f64, f64, f64)) -> Option { + fn area_plot_job(&self, window: (f64, f64, f64, f64)) -> Option { use crate::io::paper_sizes::{window_to_sheet, PlotScale}; let i = self.active_tab; let (x0, y0, x1, y1) = window; @@ -4854,24 +4687,19 @@ pub(super) fn on_open_file(&mut self) -> Task { }; let scene = &self.tabs[i].scene; let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); - let (all_wires, hatches, wipeouts, mut group_splits) = - plot_scene_content( + let mut content = plot_scene_content( scene, self.plot_dialog.paperspace_last, plot_render_mode_override(&self.plot_dialog), ); - let first_wire_count = all_wires[..group_splits.wires.min(all_wires.len())] - .iter() - .filter(|w| w.name != "paper_printable_area") - .filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0) - .count(); - let wires: Vec<_> = all_wires - .iter() - .filter(|w| w.name != "paper_printable_area") - .filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0) - .cloned() - .collect(); - group_splits.wires = first_wire_count; + let in_window = |wire: &crate::io::pdf_export::PlotWire| { + wire.name != "paper_printable_area" + && wire.aabb[0] <= wx1 && wire.aabb[2] >= wx0 + && wire.aabb[1] <= wy1 && wire.aabb[3] >= wy0 + }; + content.group_splits.wires = content.wires[..content.group_splits.wires.min(content.wires.len())] + .iter().filter(|wire| in_window(wire)).count(); + std::sync::Arc::make_mut(&mut content.wires).retain(in_window); let offset_x = (target_x / scale) - x0; let offset_y = (target_y / scale) - y0; let clip = Some(( @@ -4885,19 +4713,18 @@ pub(super) fn on_open_file(&mut self) -> Task { 90 | 270 => (sheet_h, sheet_w), _ => (sheet_w, sheet_h), }; - Some(( - wires, - hatches, - wipeouts, - group_splits, - page_w, - page_h, + Some(PdfPageInput { + content, + paper_w: page_w, + paper_h: page_h, offset_x, offset_y, - rotation, - scale as f32, + rotation_deg: rotation, + scale: scale as f32, clip, - )) + options: Self::pdf_plot_options(&self.plot_dialog), + plot_style: self.dialog_plot_style(&self.plot_dialog), + }) } pub(super) fn on_plot_style_panel_apply(&mut self) -> Task { diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 5dc8161e1..840555a8e 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -13,6 +13,7 @@ use crate::scene::model::hatch_model::HatchModel; #[cfg(not(target_arch = "wasm32"))] use crate::scene::model::hatch_model::HatchPattern; use crate::scene::WireModel; +use crate::scene::model::image_model::ImageModel; #[cfg(not(target_arch = "wasm32"))] use printpdf::{ BlendMode, BuiltinFont, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, Line, @@ -37,25 +38,18 @@ impl std::ops::Deref for PlotWire { } } +/// Decoded image geometry plus inherited block/viewport clip boundaries. +#[derive(Clone, Debug)] +pub struct PlotImage { + pub image: ImageModel, + pub clips: Vec>, +} + // The web build has no `printpdf` (it pulls a wasm-incompatible `memchr` via // lopdf → nom_locate) and no filesystem, so PDF export is native-only; the web // build gets these stubs so the call sites still compile. #[cfg(target_arch = "wasm32")] -pub fn export_pdf( - _wires: &[PlotWire], - _hatches: &[HatchModel], - _wipeouts: &[HatchModel], - _paper_w: f64, - _paper_h: f64, - _offset_x: f64, - _offset_y: f64, - _rotation_deg: i32, - _scale: f32, - _clip: Option<(f32, f32, f32, f32)>, - _path: &Path, - _plot_style: Option<&PlotStyleTable>, - _options: PdfPlotOptions, -) -> Result<(), String> { +pub fn export_pdf(_page: &PdfPageInput, _path: &Path) -> Result<(), String> { Err("PDF export is not available in the web version.".into()) } @@ -94,7 +88,6 @@ pub struct PdfPlotOptions { pub transparency: bool, pub stamp: bool, pub merge_lines: bool, - pub group_splits: PlotGroupSplits, } /// End indexes of the first paper/model render group in each flat input list. @@ -103,16 +96,26 @@ pub struct PlotGroupSplits { pub wires: usize, pub hatches: usize, pub wipeouts: usize, + pub images: usize, } -/// Owned render data for one page in a multi-page PDF. -#[cfg_attr(target_arch = "wasm32", allow(dead_code))] -pub struct PdfPageInput { +#[derive(Default)] +pub struct PlotContent { pub wires: std::sync::Arc>, pub hatches: Vec, pub wipeouts: Vec, + pub images: Vec, + pub group_splits: PlotGroupSplits, +} + +/// Owned geometry and settings for one PDF page. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub struct PdfPageInput { + pub content: PlotContent, + /// Page dimensions in mm, after any 90/270-degree rotation. pub paper_w: f64, pub paper_h: f64, + /// Absolute-world offsets stay f64 to preserve local detail at UTM coordinates. pub offset_x: f64, pub offset_y: f64, pub rotation_deg: i32, @@ -130,50 +133,16 @@ impl Default for PdfPlotOptions { transparency: false, stamp: false, merge_lines: false, - group_splits: PlotGroupSplits::default(), } } } // ── Public entry point ──────────────────────────────────────────────────── -/// Export `wires` to a PDF file. -/// -/// - `paper_w` / `paper_h`: page dimensions in mm (already swapped for 90°/270° by caller). -/// - `offset_x` / `offset_y`: added to every wire coordinate so the drawing -/// origin maps to the bottom-left corner of the page. -/// - `rotation_deg`: 0 | 90 | 180 | 270 — rotates the entire drawing on the page. +/// Export one page to a PDF file. #[cfg(not(target_arch = "wasm32"))] -pub fn export_pdf( - wires: &[PlotWire], - hatches: &[HatchModel], - wipeouts: &[HatchModel], - paper_w: f64, - paper_h: f64, - offset_x: f64, - offset_y: f64, - rotation_deg: i32, - scale: f32, - clip: Option<(f32, f32, f32, f32)>, - path: &Path, - plot_style: Option<&PlotStyleTable>, - options: PdfPlotOptions, -) -> Result<(), String> { - let bytes = build_pdf( - wires, - hatches, - wipeouts, - paper_w as f32, - paper_h as f32, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - plot_style, - options, - ); - write_pdf_atomically(path, &bytes) +pub fn export_pdf(page: &PdfPageInput, path: &Path) -> Result<(), String> { + export_pdf_pages(std::slice::from_ref(page), path, None) } /// Export several independently sized pages into one PDF file. @@ -186,7 +155,7 @@ pub fn export_pdf_pages( if pages.is_empty() { return Err("No pages were selected.".into()); } - let bytes = build_pdf_pages(pages, plot_style); + let bytes = build_pdf_pages(pages, plot_style)?; write_pdf_atomically(path, &bytes) } @@ -205,6 +174,202 @@ fn write_pdf_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> { Ok(()) } +#[cfg(not(target_arch = "wasm32"))] +fn image_clip(ops: &mut Vec, rings: Vec>) { + ops.push(Op::DrawPolygon { + polygon: Polygon { + rings: rings + .into_iter() + .map(|ring| PolygonRing { + points: ring + .into_iter() + .map(|[x, y]| LinePoint { + p: Point { x: Pt(x), y: Pt(y) }, + bezier: false, + }) + .collect(), + }) + .collect(), + mode: PaintMode::Clip, + winding_order: WindingOrder::EvenOdd, + }, + }); +} + +#[cfg(not(target_arch = "wasm32"))] +fn emit_image( + doc: &mut PdfDocument, + resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>, + ops: &mut Vec, + plot: &PlotImage, + ox: f64, + oy: f64, + options: PdfPlotOptions, +) -> Result<(), String> { + let image = &plot.image; + let expected_byte_count = (image.width as usize) + .checked_mul(image.height as usize) + .and_then(|n| n.checked_mul(4)); + if image.width == 0 || image.height == 0 || expected_byte_count != Some(image.pixels.len()) { + return Err("Cannot plot bitmap: dimensions do not match its RGBA pixels.".into()); + } + if image.verts.len() < 3 || image.verts.len() % 3 != 0 { + return Err("Cannot plot bitmap: incomplete triangle geometry.".into()); + } + if options.transparency && image.opacity <= 0.0 { + return Ok(()); + } + let to_page = |high: [f32; 3], low: [f32; 3]| { + [ + ((high[0] as f64 + low[0] as f64 + ox) * MM_TO_PT as f64) as f32, + ((high[1] as f64 + low[1] as f64 + oy) * MM_TO_PT as f64) as f32, + ] + }; + let corners: [[f32; 2]; 4] = + std::array::from_fn(|i| to_page(image.corners[i], image.corners_low[i])); + if !corners.iter().flatten().all(|v| v.is_finite()) + || !image.verts.iter().all(|v| { + to_page(v.pos, v.pos_low) + .iter() + .chain(&v.uv) + .all(|c| c.is_finite()) + }) + || !plot + .clips + .iter() + .all(|ring| ring.len() >= 3 && ring.iter().flatten().all(|v| v.is_finite())) + || !image.opacity.is_finite() + { + return Err("Cannot plot bitmap: invalid coordinates, clip boundary, or opacity.".into()); + } + let key = ( + std::sync::Arc::as_ptr(&image.pixels) as usize, + image.width, + image.height, + ); + let id = resources + .entry(key) + .or_insert_with(|| { + let id = printpdf::XObjectId::new(); + // add_image clones its argument; the public map accepts owned pixels. + doc.resources.xobjects.map.insert( + id.clone(), + printpdf::XObject::Image(printpdf::RawImage { + pixels: printpdf::RawImageData::U8(image.pixels.as_ref().clone()), + width: image.width as usize, + height: image.height as usize, + data_format: printpdf::RawImageFormat::RGBA8, + tag: Vec::new(), + }), + ); + id + }) + .clone(); + ops.push(Op::SaveGraphicsState); + if options.transparency && image.opacity < 1.0 { + // printpdf 0.9.1 swaps the serialized CA/ca keys. Set both inside + // this image's saved state so the nonstroking alpha is correct. + let alpha = image.opacity.clamp(0.0, 1.0); + let gs = doc.add_graphics_state( + ExtendedGraphicsState::default() + .with_current_fill_alpha(alpha) + .with_current_stroke_alpha(alpha), + ); + ops.push(Op::LoadGraphicsState { gs }); + } + for clip in &plot.clips { + image_clip( + ops, + vec![clip + .iter() + .map(|p| { + [ + ((p[0] + ox) * MM_TO_PT as f64) as f32, + ((p[1] + oy) * MM_TO_PT as f64) as f32, + ] + }) + .collect()], + ); + } + let draw = |ops: &mut Vec, matrix: [f32; 6]| { + ops.push(Op::SetTransformationMatrix { + matrix: printpdf::CurTransMat::Raw(matrix), + }); + ops.push(Op::UseXobject { + id: id.clone(), + // Cancel printpdf's pixel-size transform: matrix maps the PDF + // image's unit square directly onto the CAD quad in page points. + transform: printpdf::XObjectTransform { + dpi: Some(72.0), + scale_x: Some(1.0 / image.width as f32), + scale_y: Some(1.0 / image.height as f32), + ..Default::default() + }, + }); + }; + let u = [corners[1][0] - corners[0][0], corners[1][1] - corners[0][1]]; + let v = [corners[3][0] - corners[0][0], corners[3][1] - corners[0][1]]; + let affine = (0..2).all(|axis| { + (corners[2][axis] - corners[0][axis] - u[axis] - v[axis]).abs() + <= 1e-5 * (u[axis].abs() + v[axis].abs()).max(1.0) + }); + if affine { + // A single draw preserves intrinsic IMAGE clipping without overlapping image draws. + image_clip( + ops, + image + .verts + .chunks_exact(3) + .map(|tri| { + tri.iter() + .map(|vertex| to_page(vertex.pos, vertex.pos_low)) + .collect() + }) + .collect(), + ); + draw(ops, [u[0], u[1], v[0], v[1], corners[0][0], corners[0][1]]); + } else { + // PDF transforms are affine; perspective sampling is approximate. + for triangle in image.verts.chunks_exact(3) { + let page = std::array::from_fn(|i| to_page(triangle[i].pos, triangle[i].pos_low)); + let uv = std::array::from_fn(|i| [triangle[i].uv[0], 1.0 - triangle[i].uv[1]]); + let matrix = image_triangle_matrix(page, uv) + .filter(|matrix| matrix.iter().all(|v| v.is_finite())) + .ok_or("Cannot plot bitmap: degenerate texture mapping.")?; + ops.push(Op::SaveGraphicsState); + image_clip(ops, vec![page.to_vec()]); + draw(ops, matrix); + ops.push(Op::RestoreGraphicsState); + } + } + ops.push(Op::RestoreGraphicsState); + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn image_triangle_matrix(p: [[f32; 2]; 3], uv: [[f32; 2]; 3]) -> Option<[f32; 6]> { + let u = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]]; + let v = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]]; + let determinant = u[0] * v[1] - v[0] * u[1]; + if determinant.abs() < 1e-12 { + return None; + } + let axes: [[f32; 2]; 2] = std::array::from_fn(|i| { + [ + ((p[1][i] - p[0][i]) * v[1] - (p[2][i] - p[0][i]) * u[1]) / determinant, + (u[0] * (p[2][i] - p[0][i]) - v[0] * (p[1][i] - p[0][i])) / determinant, + ] + }); + Some([ + axes[0][0], + axes[1][0], + axes[0][1], + axes[1][1], + p[0][0] - axes[0][0] * uv[0][0] - axes[0][1] * uv[0][1], + p[0][1] - axes[1][0] * uv[0][0] - axes[1][1] * uv[0][1], + ]) +} + /// Show a parented PDF save-file dialog and return the chosen path. /// /// The parent comes from `iced::window::run`, keeping the portal request tied @@ -230,83 +395,32 @@ pub fn pick_pdf_path_owned( // ── PDF builder ─────────────────────────────────────────────────────────── #[cfg(not(target_arch = "wasm32"))] -fn build_pdf( - wires: &[PlotWire], - hatches: &[HatchModel], - wipeouts: &[HatchModel], - paper_w: f32, - paper_h: f32, - // Absolute-world offsets, kept in f64: at UTM the drawing sits at ~5e5/4.5e6 - // where an f32 has ~0.03 m / ~0.5 m of resolution, so an f32 offset is itself - // already quantised before it can cancel the coordinate it is meant to cancel. - ox: f64, - oy: f64, - rotation_deg: i32, - scale: f32, - clip: Option<(f32, f32, f32, f32)>, - plot_style: Option<&PlotStyleTable>, - options: PdfPlotOptions, -) -> Vec { +fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) -> Result, String> { let mut doc = PdfDocument::new("Open CAD Studio Export"); - append_pdf_page( - &mut doc, - wires, - hatches, - wipeouts, - paper_w, - paper_h, - ox, - oy, - rotation_deg, - scale, - clip, - plot_style, - options, - ); - let mut warnings = Vec::new(); - doc.save(&PdfSaveOptions::default(), &mut warnings) -} - -#[cfg(not(target_arch = "wasm32"))] -fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) -> Vec { - let mut doc = PdfDocument::new("Open CAD Studio Export"); - for page in pages { - append_pdf_page( - &mut doc, - &page.wires, - &page.hatches, - &page.wipeouts, - page.paper_w as f32, - page.paper_h as f32, - page.offset_x, - page.offset_y, - page.rotation_deg, - page.scale, - page.clip, - page.plot_style.as_ref().or(plot_style), - page.options, - ); + // Borrowing all pages keeps their pixel Arcs alive until this cache is dropped. + // Allocation addresses cannot be reused by another source during this export. + let mut image_resources = std::collections::HashMap::new(); + for (index, page) in pages.iter().enumerate() { + append_pdf_page(&mut doc, &mut image_resources, page, plot_style) + .map_err(|error| format!("Page {}: {error}", index + 1))?; } let mut warnings = Vec::new(); - doc.save(&PdfSaveOptions::default(), &mut warnings) + Ok(doc.save(&PdfSaveOptions::default(), &mut warnings)) } #[cfg(not(target_arch = "wasm32"))] fn append_pdf_page( doc: &mut PdfDocument, - wires: &[PlotWire], - hatches: &[HatchModel], - wipeouts: &[HatchModel], - paper_w: f32, - paper_h: f32, - ox: f64, - oy: f64, - rotation_deg: i32, - scale: f32, - clip: Option<(f32, f32, f32, f32)>, - plot_style: Option<&PlotStyleTable>, - options: PdfPlotOptions, -) { + image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>, + page: &PdfPageInput, + fallback_plot_style: Option<&PlotStyleTable>, +) -> Result<(), String> { + let PlotContent { wires, hatches, wipeouts, images, group_splits } = &page.content; + let (paper_w, paper_h) = (page.paper_w as f32, page.paper_h as f32); + let (ox, oy) = (page.offset_x, page.offset_y); + let (rotation_deg, scale, clip) = (page.rotation_deg, page.scale, page.clip); + let plot_style = page.plot_style.as_ref().or(fallback_plot_style); + let options = page.options; let mut ops: Vec = Vec::new(); // White page background. @@ -411,23 +525,26 @@ fn append_pdf_page( let (first_wires, second_wires) = - wires.split_at(options.group_splits.wires.min(wires.len())); + wires.split_at(group_splits.wires.min(wires.len())); let (first_hatches, second_hatches) = - hatches.split_at(options.group_splits.hatches.min(hatches.len())); + hatches.split_at(group_splits.hatches.min(hatches.len())); let (first_wipeouts, second_wipeouts) = - wipeouts.split_at(options.group_splits.wipeouts.min(wipeouts.len())); - for (wires, hatches, wipeouts) in [ - (first_wires, first_hatches, first_wipeouts), - (second_wires, second_hatches, second_wipeouts), + wipeouts.split_at(group_splits.wipeouts.min(wipeouts.len())); + let (first_images, second_images) = + images.split_at(group_splits.images.min(images.len())); + for (wires, hatches, wipeouts, images) in [ + (first_wires, first_hatches, first_wipeouts, first_images), + (second_wires, second_hatches, second_wipeouts, second_images), ] { enum DrawItem<'a> { WireFill(&'a PlotWire), Hatch(&'a HatchModel), + Image(&'a PlotImage), Wire(&'a PlotWire), Text(&'a PlotWire), } - let mut draw_items = Vec::with_capacity(wires.len() * 2 + hatches.len() + wipeouts.len()); + let mut draw_items = Vec::with_capacity(wires.len() * 2 + hatches.len() + wipeouts.len() + images.len()); let mut sequence = 0usize; for wire in wires { if !wire.fill_tris.is_empty() { @@ -445,6 +562,10 @@ fn append_pdf_page( draw_items.push((hatch.draw_depth, 1u8, sequence, DrawItem::Hatch(hatch))); sequence += 1; } + for image in images { + draw_items.push((image.image.draw_depth, 1u8, sequence, DrawItem::Image(image))); + sequence += 1; + } draw_items.sort_by(|a, b| { a.0.total_cmp(&b.0) .then_with(|| a.1.cmp(&b.1)) @@ -508,6 +629,10 @@ fn append_pdf_page( last_dash = None; continue; } + DrawItem::Image(image) => { + emit_image(doc, image_resources, &mut ops, image, ox, oy, options)?; + continue; + } DrawItem::Wire(wire) => wire, }; let [mut r, mut g, mut b, a] = wire.color; @@ -722,6 +847,7 @@ fn append_pdf_page( let page = PdfPage::new(Mm(paper_w), Mm(paper_h), ops); doc.pages.push(page); + Ok(()) } /// Build a PDF dash array (in points) from a WireModel linetype pattern. @@ -1517,6 +1643,21 @@ fn emit_text( mod tests { use super::*; + fn test_page(wires: Vec) -> PdfPageInput { + PdfPageInput { + content: PlotContent { wires: std::sync::Arc::new(wires), ..Default::default() }, + paper_w: 210.0, + paper_h: 297.0, + offset_x: 0.0, + offset_y: 0.0, + rotation_deg: 0, + scale: 1.0, + clip: None, + options: PdfPlotOptions::default(), + plot_style: None, + } + } + #[test] fn atomic_write_replaces_an_existing_pdf() { let path = std::env::temp_dir().join(format!( @@ -1546,20 +1687,10 @@ mod tests { ), draw_depth: 0.0, }; - let bytes = build_pdf( - &[w], - &[], - &[], - 210.0, - 297.0, - 0.0, - 0.0, - 0, - 2.0, - Some((10.0, 10.0, 100.0, 100.0)), - None, - PdfPlotOptions::default(), - ); + let mut page = test_page(vec![w]); + page.scale = 2.0; + page.clip = Some((10.0, 10.0, 100.0, 100.0)); + let bytes = build_pdf_pages(&[page], None).unwrap(); // A valid PDF is produced (starts with the PDF header) and is non-trivial. assert!(bytes.starts_with(b"%PDF"), "not a PDF"); assert!(bytes.len() > 200, "suspiciously small: {}", bytes.len()); @@ -1594,34 +1725,8 @@ mod tests { let mut blank = wire.clone(); blank.wire.text_verts.clear(); - let with_text = build_pdf( - &[wire], - &[], - &[], - 210.0, - 297.0, - 0.0, - 0.0, - 0, - 1.0, - None, - None, - PdfPlotOptions::default(), - ); - let no_text = build_pdf( - &[blank], - &[], - &[], - 210.0, - 297.0, - 0.0, - 0.0, - 0, - 1.0, - None, - None, - PdfPlotOptions::default(), - ); + let with_text = build_pdf_pages(&[test_page(vec![wire])], None).unwrap(); + let no_text = build_pdf_pages(&[test_page(vec![blank])], None).unwrap(); assert!(with_text.starts_with(b"%PDF")); assert!( with_text.len() > no_text.len(), diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 6f4d3a680..425b06e9f 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -9,9 +9,6 @@ #[cfg(not(target_arch = "wasm32"))] use crate::io::pdf_export; -use crate::io::plot_style::PlotStyleTable; -use crate::scene::model::hatch_model::HatchModel; -use crate::io::pdf_export::PlotWire; #[cfg(not(target_arch = "wasm32"))] pub(crate) fn temp_pdf_path(kind: &str) -> std::path::PathBuf { @@ -50,8 +47,6 @@ pub struct PrintOptions { /// setting wins, so the field is legitimately unread there. #[cfg_attr(target_os = "windows", allow(dead_code))] pub quality: Option, - /// Controls applied while building the intermediate PDF. - pub render: crate::io::pdf_export::PdfPlotOptions, } #[cfg(target_arch = "wasm32")] @@ -60,19 +55,8 @@ pub fn list_printers() -> Vec { } #[cfg(target_arch = "wasm32")] -#[allow(clippy::too_many_arguments)] pub async fn print_wires_with( - _wires: std::sync::Arc>, - _hatches: Vec, - _wipeouts: Vec, - _paper_w: f64, - _paper_h: f64, - _offset_x: f64, - _offset_y: f64, - _rotation_deg: i32, - _scale: f32, - _clip: Option<(f32, f32, f32, f32)>, - _plot_style: Option, + _page: crate::io::pdf_export::PdfPageInput, _opts: PrintOptions, ) -> Result { Err("Printing is not available in the web version.".into()) @@ -172,46 +156,18 @@ pub fn open_printer_properties(_printer: Option<&str>) -> Result<(), String> { Err("Printer properties are not available in the web version.".into()) } -/// Like [`print_wires`] but honours a [`PrintOptions`] bundle (printer, copies, -/// grayscale, quality, DPI). +/// Render a page and send it to the selected printer. #[cfg(not(target_arch = "wasm32"))] -#[allow(clippy::too_many_arguments)] pub async fn print_wires_with( - wires: std::sync::Arc>, - hatches: Vec, - wipeouts: Vec, - paper_w: f64, - paper_h: f64, - offset_x: f64, - offset_y: f64, - rotation_deg: i32, - scale: f32, - clip: Option<(f32, f32, f32, f32)>, - plot_style: Option, + page: crate::io::pdf_export::PdfPageInput, opts: PrintOptions, ) -> Result { let tmp_path = temp_pdf_path("print"); - pdf_export::export_pdf( - &wires, - &hatches, - &wipeouts, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - &tmp_path, - plot_style.as_ref(), - opts.render, - )?; + pdf_export::export_pdf(&page, &tmp_path)?; dispatch_to_printer_opts(&tmp_path, &opts) } -/// Send an already-rendered PDF to a printer with [`PrintOptions`]. Used for -/// clipped window plots, whose PDF is built with a scale + clip the plain -/// `print_wires_with` path doesn't expose. +/// Send an already-rendered PDF to the selected printer. #[cfg(not(target_arch = "wasm32"))] pub fn print_existing_pdf(path: &std::path::Path, opts: &PrintOptions) -> Result { dispatch_to_printer_opts(path, opts) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d36fef59f..e5844afb0 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -6648,6 +6648,49 @@ impl Scene { all_visible: bool, viewport: Option, ) -> Vec { + self.collect_images(target_block, frozen, annotation_scale_handle, all_visible, + viewport, false, |image, _| image) + } + + pub fn paper_plot_images(&self) -> Vec { + let scale = if self.current_layout == "Model" { + self.displayed_annotation_scale_handle() + } else { + self.paper_annotation_scale_handle() + }; + self.placed_images(self.current_layout_block_handle(), None, + scale, self.annotation_all_visible(), None, true) + } + + fn placed_images( + &self, + target_block: Handle, + frozen: Option<&HashSet>, + annotation_scale_handle: Option, + all_visible: bool, + viewport: Option, + plotting: bool, + ) -> Vec { + self.collect_images(target_block, frozen, annotation_scale_handle, all_visible, + viewport, plotting, |image, context| crate::io::pdf_export::PlotImage { + image, + clips: context.clips.clone(), + }) + } + + fn collect_images( + &self, + target_block: Handle, + frozen: Option<&HashSet>, + annotation_scale_handle: Option, + all_visible: bool, + viewport: Option, + plotting: bool, + mut collect: impl FnMut(ImageModel, &render_graph::InstanceContext) -> T, + ) -> Vec { + if self.images.is_empty() { + return Vec::new(); + } let depth_map = self.draw_depth_map(); let graph = render_graph::RenderSceneGraph::new( &self.document, @@ -6673,8 +6716,10 @@ impl Scene { graph.walk_root( root, |entity, context| { - context.is_instanced() - || !self.entity_temporarily_hidden(entity.common().handle) + (!plotting || self.document.layers.get(&entity.common().layer) + .is_none_or(|layer| layer.is_plottable)) + && (context.is_instanced() + || !self.entity_temporarily_hidden(entity.common().handle)) }, |entity, context| { let handle = entity.common().handle; @@ -6734,7 +6779,7 @@ impl Scene { ); } placed.draw_depth = context.draw_depth(handle, depth_map.as_ref()); - models.push(placed); + models.push(collect(placed, context)); }, ); models diff --git a/src/scene/project.rs b/src/scene/project.rs index 29ef0735d..f20c10e38 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -386,12 +386,12 @@ impl Scene { /// needs the equivalent paper-space geometry explicitly. pub fn viewport_plot_fills( &self, - ) -> (Vec<(WireModel, f32)>, Vec, Vec) { + ) -> (Vec<(WireModel, f32)>, Vec, Vec, Vec) { use acadrust::entities::Viewport; use model::hatch_model::HatchPattern; if self.current_layout == "Model" { - return (Vec::new(), Vec::new(), Vec::new()); + return (Vec::new(), Vec::new(), Vec::new(), Vec::new()); } let paper_block = self.current_layout_block_handle(); let model_block = self.model_space_block_handle(); @@ -411,6 +411,7 @@ impl Scene { let mut pattern_wires = Vec::new(); let mut projected_hatches = Vec::new(); let mut projected_wipeouts = Vec::new(); + let mut projected_images = Vec::new(); for viewport in viewports { let Some(camera) = self.camera_for_viewport(viewport.common.handle) else { @@ -463,6 +464,58 @@ impl Scene { let frozen: rustc_hash::FxHashSet = viewport.frozen_layers.iter().copied().collect(); + let mut viewport_clips = vec![vec![ + [xmin as f64, ymin as f64], [xmax as f64, ymin as f64], + [xmax as f64, ymax as f64], [xmin as f64, ymax as f64], + ]]; + if !self.images.is_empty() && !viewport.clip_boundary_handle.is_null() { + let boundary = self.clip_boundary_polygon( + viewport.clip_boundary_handle, viewport.center.z as f32); + if boundary.len() >= 3 { + viewport_clips.push(boundary.iter().map(|p| [p[0] as f64, p[1] as f64]).collect()); + } + } + for mut plot in self.placed_images(model_block, Some(&frozen), + self.viewport_scale_handle(viewport.common.handle), + self.annotation_all_visible(), Some(viewport.common.handle), true) + { + let mut valid = true; + let mut project_point = |high: &mut [f32; 3], low: &mut [f32; 3]| { + let point = std::array::from_fn(|i| high[i] as f64 + low[i] as f64); + if let Some([x, y]) = project_3d(point) { + *high = [x, y, 0.0]; + *low = [0.0; 3]; + } else { + valid = false; + } + }; + for (high, low) in plot.image.corners.iter_mut().zip(&mut plot.image.corners_low) { + project_point(high, low); + } + for vertex in &mut plot.image.verts { + project_point(&mut vertex.pos, &mut vertex.pos_low); + } + for clip in &mut plot.clips { + for point in clip { + if let Some([x, y]) = project(point[0], point[1]) { + *point = [x as f64, y as f64]; + } else { + valid = false; + } + } + } + if !valid { + continue; + } + if [(0, xmin, xmax), (1, ymin, ymax)].iter().any(|&(axis, min, max)| { + plot.image.verts.iter().all(|v| v.pos[axis] < min) + || plot.image.verts.iter().all(|v| v.pos[axis] > max) + }) { + continue; + } + plot.clips.extend(viewport_clips.iter().cloned()); + projected_images.push(plot); + } let hatches = self.plot_hatches_for_block( model_block, Some(&frozen), @@ -536,7 +589,7 @@ impl Scene { } } - (pattern_wires, projected_hatches, projected_wipeouts) + (pattern_wires, projected_hatches, projected_wipeouts, projected_images) } /// A content viewport's clip boundary, projected into that viewport's diff --git a/tests/fixtures/embedded-image-print.dxf b/tests/fixtures/embedded-image-print.dxf new file mode 100644 index 000000000..6fa5972f6 --- /dev/null +++ b/tests/fixtures/embedded-image-print.dxf @@ -0,0 +1,7522 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1024 + 9 +$ACADMAINTVER + 70 +6 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +Synthetic reproduction + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +OpenSans + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +0.175 + 9 +$DIMEXO + 40 +0.125 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +0.375 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +0.25 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +12 + 9 +$DIMBLK + 1 +ARCHTICK + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +100.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +EZDXF + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +0.5 + 9 +$DIMGAP + 40 +0.1 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +1 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +OpenSansCondensed-Light + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +2 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +3 + 9 +$DIMDSEP + 70 +46 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +2 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +4 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +2 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2461298.5564930555 + 9 +$TDUCREATE + 40 +2458532.153996898 + 9 +$TDUPDATE + 40 +2461298.5564930555 + 9 +$TDUUPDATE + 40 +2458532.1544311 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +99 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +0 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +-0.0 + 20 +-0.0 + 9 +$PLIMMAX + 10 +11.0 + 20 +8.5 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +0 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +1 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{AE9B3164-57C5-40A6-B897-3AA314A90313} + 9 +$VERSIONGUID + 2 +{DBBE3EFA-6A7C-4482-B175-433C33A11394} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +8B +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +5.5 + 22 +4.25 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +12.0 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +1 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +21 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +5.08 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTERX2 + 70 +0 + 3 +Center (2x) ________ __ ________ __ ________ + 72 +65 + 73 +4 + 40 +8.89 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER2 + 70 +0 + 3 +Center (.5x) ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.54 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +1.524 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHEDX2 + 70 +0 + 3 +Dashed (2x) ____ ____ ____ ____ ____ ____ + 72 +65 + 73 +2 + 40 +3.048 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +34 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED2 + 70 +0 + 3 +Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ + 72 +65 + 73 +2 + 40 +0.762 + 49 +0.635 + 74 +0 + 49 +-0.127 + 74 +0 + 0 +LTYPE + 5 +35 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +6.35 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +36 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOMX2 + 70 +0 + 3 +Phantom (2x)____________ ____ ____ ____________ + 72 +65 + 73 +6 + 40 +10.795 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +37 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM2 + 70 +0 + 3 +Phantom (.5x) ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ + 72 +65 + 73 +6 + 40 +3.175 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +38 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +3.556 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +39 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOTX2 + 70 +0 + 3 +Dash dot (2x) ____ . ____ . ____ . ____ + 72 +65 + 73 +4 + 40 +6.096 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3A +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT2 + 70 +0 + 3 +Dash dot (.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +4 + 40 +1.778 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3B +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT + 70 +0 + 3 +Dot . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.508 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3C +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOTX2 + 70 +0 + 3 +Dot (2x) . . . . . . . . + 72 +65 + 73 +2 + 40 +1.016 + 49 +0.0 + 74 +0 + 49 +-1.016 + 74 +0 + 0 +LTYPE + 5 +3D +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT2 + 70 +0 + 3 +Dot (.5) . . . . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.254 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3E +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE + 70 +0 + 3 +Divide __ . . __ . . __ . . __ . . __ . . __ + 72 +65 + 73 +6 + 40 +4.064 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDEX2 + 70 +0 + 3 +Divide (2x) ____ . . ____ . . ____ . . ____ + 72 +65 + 73 +6 + 40 +6.604 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +40 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE2 + 70 +0 + 3 +Divide(.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +6 + 40 +2.032 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +26 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +STYLE + 5 +41 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-Light + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-Light.ttf + 4 + + 0 +STYLE + 5 +42 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-Light-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-LightItalic.ttf + 4 + + 0 +STYLE + 5 +43 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-Regular.ttf + 4 + + 0 +STYLE + 5 +44 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-Italic.ttf + 4 + + 0 +STYLE + 5 +45 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-SemiBold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-SemiBold.ttf + 4 + + 0 +STYLE + 5 +46 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-SemiBoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-SemiBoldItalic.ttf + 4 + + 0 +STYLE + 5 +47 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-Bold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-Bold.ttf + 4 + + 0 +STYLE + 5 +48 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-BoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-BoldItalic.ttf + 4 + + 0 +STYLE + 5 +49 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-ExtraBold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-ExtraBold.ttf + 4 + + 0 +STYLE + 5 +4A +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-ExtraBoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-ExtraBoldItalic.ttf + 4 + + 0 +STYLE + 5 +4B +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSansCondensed-Bold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSansCondensed-Bold.ttf + 4 + + 0 +STYLE + 5 +4C +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSansCondensed-Light + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSansCondensed-Light.ttf + 4 + + 0 +STYLE + 5 +4D +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSansCondensed-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSansCondensed-LightItalic.ttf + 4 + + 0 +STYLE + 5 +4E +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSans + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSans-Regular.ttf + 4 + + 0 +STYLE + 5 +4F +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSans-Bold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSans-Bold.ttf + 4 + + 0 +STYLE + 5 +50 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSans-BoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSans-BoldItalic.ttf + 4 + + 0 +STYLE + 5 +51 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSans-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSans-Italic.ttf + 4 + + 0 +STYLE + 5 +52 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSerif + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSerif-Regular.ttf + 4 + + 0 +STYLE + 5 +53 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSerif-Bold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSerif-Bold.ttf + 4 + + 0 +STYLE + 5 +54 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSerif-BoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSerif-BoldItalic.ttf + 4 + + 0 +STYLE + 5 +55 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSerif-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSerif-Italic.ttf + 4 + + 0 +STYLE + 5 +56 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationMono + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationMono-Regular.ttf + 4 + + 0 +STYLE + 5 +57 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationMono-Bold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationMono-Bold.ttf + 4 + + 0 +STYLE + 5 +58 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationMono-BoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationMono-BoldItalic.ttf + 4 + + 0 +STYLE + 5 +59 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationMono-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationMono-Italic.ttf + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +96 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +97 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +12 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +73 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZDXF + 70 +0 + 40 +1.0 + 41 +0.175 + 42 +0.125 + 43 +3.75 + 44 +0.375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.25 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.1 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +342 +74 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +7C +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_100_H25_CM + 70 +0 + 40 +1.0 + 41 +0.25 + 42 +0.125 + 43 +3.75 + 44 +0.375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.25 +141 +2.5 +142 +0.125 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.1 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +7D +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_50_H25_CM + 70 +0 + 40 +1.0 + 41 +0.125 + 42 +0.0625 + 43 +3.75 + 44 +0.1875 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.125 +141 +2.5 +142 +0.0625 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.05 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +7E +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_25_H25_CM + 70 +0 + 40 +1.0 + 41 +0.0625 + 42 +0.03125 + 43 +3.75 + 44 +0.09375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.0625 +141 +2.5 +142 +0.03125 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.025 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +7F +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_20_H25_CM + 70 +0 + 40 +1.0 + 41 +0.05 + 42 +0.025 + 43 +3.75 + 44 +0.07500000000000001 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.05 +141 +2.5 +142 +0.025 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.020000000000000004 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +80 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_10_H25_CM + 70 +0 + 40 +1.0 + 41 +0.025 + 42 +0.0125 + 43 +3.75 + 44 +0.037500000000000006 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.025 +141 +2.5 +142 +0.0125 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.010000000000000002 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +81 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_5_H25_CM + 70 +0 + 40 +1.0 + 41 +0.0125 + 42 +0.00625 + 43 +3.75 + 44 +0.018750000000000003 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.0125 +141 +2.5 +142 +0.00625 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.005000000000000001 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +82 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_1_H25_CM + 70 +0 + 40 +1.0 + 41 +0.0025 + 42 +0.00125 + 43 +3.75 + 44 +0.00375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.0025 +141 +2.5 +142 +0.00125 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.001 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +83 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_RADIUS + 70 +0 + 40 +1.0 + 41 +0.25 + 42 +0.125 + 43 +3.75 + 44 +0.375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.25 +141 +0.0 +142 +0.0 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.1 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +0 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +1 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +342 +84 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +88 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_RADIUS_INSIDE + 70 +0 + 40 +1.0 + 41 +0.25 + 42 +0.125 + 43 +3.75 + 44 +0.375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.25 +141 +0.0 +142 +0.0 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.1 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +0 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +0 +173 +0 +174 +1 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +0 +290 +0 +340 +4C +342 +84 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +89 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_CURVED + 70 +0 + 40 +1.0 + 41 +0.25 + 42 +0.125 + 43 +3.75 + 44 +0.375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.25 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.1 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +2 + 90 +2 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +6 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +74 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +_ARCHTICK +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +78 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +_CLOSEDFILLED +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +84 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +_CLOSEDBLANK +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +8C +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +SyntheticImage +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +75 +330 +74 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +_ARCHTICK + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +_ARCHTICK + 1 + + 0 +LWPOLYLINE + 5 +77 +330 +74 +100 +AcDbEntity + 8 +0 + 6 +BYBLOCK + 62 +0 +100 +AcDbPolyline + 90 +2 + 70 +0 + 43 +0.15 + 10 +-0.5 + 20 +-0.5 + 10 +0.5 + 20 +0.5 + 0 +ENDBLK + 5 +76 +330 +74 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +79 +330 +78 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +_CLOSEDFILLED + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +_CLOSEDFILLED + 1 + + 0 +SOLID + 5 +7B +330 +78 +100 +AcDbEntity + 8 +0 + 6 +BYBLOCK + 62 +0 +100 +AcDbTrace + 10 +-1.0 + 20 +0.16439898372402537 + 30 +0.0 + 11 +0.0 + 21 +0.0 + 31 +0.0 + 12 +-1.0 + 22 +-0.16439898372402537 + 32 +0.0 + 13 +-1.0 + 23 +-0.16439898372402537 + 33 +0.0 + 0 +ENDBLK + 5 +7A +330 +78 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +85 +330 +84 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +_CLOSEDBLANK + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +_CLOSEDBLANK + 1 + + 0 +LWPOLYLINE + 5 +87 +330 +84 +100 +AcDbEntity + 8 +0 + 6 +BYBLOCK + 62 +0 +100 +AcDbPolyline + 90 +3 + 70 +1 + 10 +-1.0 + 20 +0.16439898372402537 + 10 +0.0 + 20 +0.0 + 10 +-1.0 + 20 +-0.16439898372402537 + 0 +ENDBLK + 5 +86 +330 +84 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +8D +330 +8C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +SyntheticImage + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +SyntheticImage + 1 + + 0 +OLE2FRAME + 5 +8F +330 +8C +100 +AcDbEntity + 8 +0 +100 +AcDbOle2Frame + 70 +2 + 3 +Picture + 10 +0.0 + 20 +1.0 + 30 +0.0 + 11 +2.0 + 21 +0.0 + 31 +0.0 + 71 +3 + 72 +0 + 73 +0 + 90 +8192 +310 +D0CF11E0A1B11AE1000000000000000000000000000000003E000300FEFF090006000000000000000000000001000000000000000000000000100000FEFFFFFF00000000FEFFFFFF0000000001000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFF52006F006F007400200045006E00740072007900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016000501FFFFFFFFFFFFFFFF01000000000000000000000000000000000000000000000000000000000000000000000000000000FEFFFFFF000000 +310 +000000000043004F004E00540045004E005400530000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012000201FFFFFFFFFFFFFFFFFFFFFFFF000000000000000000000000000000000000000000000000000000000000000000000000020000003618 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +310 +0000000000000000FEFFFFFFFDFFFFFF030000000400000005000000060000000700000008000000090000000A0000000B0000000C0000000D0000000E000000FEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFF424D361800000000000036000000280000004000000020000000010018000000000000180000C40E0000C40E000000000000000000000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF +310 +0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF00FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF00 +310 +00FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000 +310 +FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF000000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF00FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF00FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000 +310 +FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF00FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000000000000000000000000000 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + 1 +OLE + 0 +ENDBLK + 5 +8E +330 +8C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +VIEWPORT + 5 +8A +330 +1B +100 +AcDbEntity + 67 +1 + 8 +VIEWPORTS +100 +AcDbViewport + 10 +5.5 + 20 +4.25 + 30 +0.0 + 40 +12.100000000000001 + 41 +9.350000000000001 + 68 +1 + 69 +1 + 12 +5.5 + 22 +4.25 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +9.350000000000001 + 50 +0.0 + 51 +0.0 + 72 +100 + 90 +557088 + 1 + +281 +0 + 71 +0 + 74 +0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 +282 +0 + 0 +INSERT + 5 +90 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbBlockReference + 2 +SyntheticImage + 10 +2.0 + 20 +4.0 + 30 +0.0 + 0 +LWPOLYLINE + 5 +92 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +2.0 + 20 +4.0 + 10 +4.0 + 20 +4.0 + 10 +4.0 + 20 +5.0 + 10 +2.0 + 20 +5.0 + 0 +TEXT + 5 +93 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbText + 10 +2.0 + 20 +5.4 + 30 +0.0 + 40 +0.2 + 1 +EMBEDDED BITMAP +100 +AcDbText + 0 +LWPOLYLINE + 5 +94 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbPolyline + 90 +3 + 70 +1 + 10 +6.0 + 20 +4.0 + 10 +8.0 + 20 +4.0 + 10 +7.0 + 20 +5.0 + 0 +TEXT + 5 +95 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbText + 10 +6.0 + 20 +5.4 + 30 +0.0 + 40 +0.2 + 1 +VECTOR CONTROL +100 +AcDbText + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +281 +1 + 3 +Model +350 +1A + 3 +Layout1 +350 +1E + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +281 +1 + 3 +2dWireframe +350 +5A + 3 +Basic +350 +5B + 3 +Brighten +350 +5C + 3 +ColorChange +350 +5D + 3 +Conceptual +350 +5E + 3 +Dim +350 +5F + 3 +EdgeColorOff +350 +60 + 3 +Facepattern +350 +61 + 3 +Flat +350 +62 + 3 +FlatWithEdges +350 +63 + 3 +Gouraud +350 +64 + 3 +GouraudWithEdges +350 +65 + 3 +Hidden +350 +66 + 3 +JitterOff +350 +67 + 3 +Linepattern +350 +68 + 3 +Modeling +350 +69 + 3 +OverhangOff +350 +6A + 3 +Realistic +350 +6B + 3 +Shaded +350 +6C + 3 +Shaded with edges +350 +6D + 3 +Shades of Gray +350 +6E + 3 +Sketchy +350 +6F + 3 +Thicken +350 +70 + 3 +Wireframe +350 +71 + 3 +X-Ray +350 +72 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 2 +Example saved printer + 4 +ezdxf_(11.00_x_8.50_Inches) + 6 + + 40 +0.0 + 41 +0.0 + 42 +0.0 + 43 +0.0 + 44 +279.4 + 45 +215.89999999999998 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +672 + 72 +0 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +0.03937007874015748 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +-0.0 + 20 +-0.0 + 11 +11.0 + 21 +8.5 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B +331 +8A + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +280 +1 +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +98 + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.3 @ 2026-09-14T18:21:21.360639+00:00 + 0 +VISUALSTYLE + 5 +5A +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +2dWireframe + 70 +4 + 90 +0 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +5B +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Basic + 70 +7 + 90 +1 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +5C +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Brighten + 70 +12 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +5D +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +ColorChange + 70 +16 + 90 +2 + 40 +0.6 + 62 +8 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +5E +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Conceptual + 70 +9 + 90 +3 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +5F +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Dim + 70 +11 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +60 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +EdgeColorOff + 70 +22 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +61 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Facepattern + 70 +15 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +62 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Flat + 70 +0 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +63 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +FlatWithEdges + 70 +1 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +64 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Gouraud + 70 +2 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +65 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +GouraudWithEdges + 70 +3 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +66 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Hidden + 70 +6 + 90 +1 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +67 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +JitterOff + 70 +20 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +68 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Linepattern + 70 +14 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +69 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Modeling + 70 +10 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +6A +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +OverhangOff + 70 +21 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +6B +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Realistic + 70 +8 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +6C +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Shaded + 70 +27 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +6D +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Shaded with edges + 70 +26 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +6E +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Shades of Gray + 70 +23 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +6F +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Sketchy + 70 +24 + 90 +1 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +70 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Thicken + 70 +13 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +71 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Wireframe + 70 +5 + 90 +0 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +72 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +X-Ray + 70 +25 + 90 +2 + 40 +0.5 + 62 +7 +290 +0 +291 +0 + 0 +DICTIONARYVAR + 5 +98 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.3 @ 2026-09-14T18:21:21.373779+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/tests/fixtures/embedded-image-print.md b/tests/fixtures/embedded-image-print.md new file mode 100644 index 000000000..8114d574d --- /dev/null +++ b/tests/fixtures/embedded-image-print.md @@ -0,0 +1,8 @@ +Synthetic regression fixture for #1266. Generated with ezdxf/Pillow from basic +geometry; it contains no external references or customer drawing content. + +Layout1 has an INSERT of SyntheticImage at (2, 4). That block contains one +OLE2FRAME whose compound-file CONTENTS stream embeds a 64 x 32 BMP: red on +the left, blue on the right, with a green diagonal from top-left to bottom-right. +The frame occupies (0, 0) to (2, 1) in the block. Text and a triangle provide +vector controls for manually opening the fixture and checking Plot Preview. diff --git a/tests/pdf_export_images_check.rs b/tests/pdf_export_images_check.rs new file mode 100644 index 000000000..c625faa36 --- /dev/null +++ b/tests/pdf_export_images_check.rs @@ -0,0 +1,512 @@ +//! #1266: decoded IMAGE/OLE content must survive the shared PDF/preview/print path. +#![cfg(not(target_arch = "wasm32"))] + +use acadrust::entities::{Insert, Viewport, Wipeout}; +use acadrust::types::{Vector2, Vector3}; +use acadrust::EntityType; +use std::sync::Arc; +use OpenCADStudio::io::pdf_export::{ + export_pdf, export_pdf_pages, PdfPageInput, PdfPlotOptions, PlotContent, PlotGroupSplits, + PlotImage, PlotWire, +}; +use OpenCADStudio::scene::model::image_model::{ImageModel, ImageQuadVertex}; +use OpenCADStudio::scene::{Scene, WireModel}; + +fn embedded_scene() -> Scene { + let mut scene = Scene::new(); + scene.document = OpenCADStudio::io::load_bytes( + "embedded-image-print.dxf", + include_bytes!("fixtures/embedded-image-print.dxf").to_vec(), + ) + .expect("synthetic embedded bitmap DXF"); + scene.images = + OpenCADStudio::scene::build_derived_caches_with_progress(&scene.document, &|_| {}, None) + .images; + scene.set_current_layout("Layout1".into()); + scene +} + +fn page(images: Vec) -> PdfPageInput { + PdfPageInput { + content: PlotContent { + images, + ..Default::default() + }, + paper_w: 100.0, + paper_h: 100.0, + offset_x: 0.0, + offset_y: 0.0, + rotation_deg: 0, + scale: 1.0, + clip: None, + options: PdfPlotOptions::default(), + plot_style: None, + } +} + +fn export(pages: &[PdfPageInput]) -> Vec { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "ocs1266-{}-{}.pdf", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + if let [page] = pages { + export_pdf(page, &path).expect("export bitmap PDF"); + } else { + export_pdf_pages(pages, &path, None).expect("export bitmap PDF pages"); + } + let bytes = std::fs::read(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + bytes +} + +struct Raster { + pixels: Vec, + width: usize, + height: usize, + scale: f32, +} +impl Raster { + fn from_pdf(bytes: &[u8], page: usize) -> Self { + Self::at_scale(bytes, page, 2.0) + } + + fn at_scale(bytes: &[u8], page: usize, scale: f32) -> Self { + let pdf = hayro::hayro_syntax::Pdf::new(Arc::new(bytes.to_vec())).unwrap(); + let pixmap = hayro::render( + &pdf.pages()[page], + &hayro::RenderCache::new(), + &hayro::hayro_interpret::InterpreterSettings::default(), + &hayro::RenderSettings { + x_scale: scale, + y_scale: scale, + bg_color: hayro::vello_cpu::color::palette::css::WHITE, + ..Default::default() + }, + ); + Self { + pixels: pixmap.data_as_u8_slice().to_vec(), + width: u32::from(pixmap.width()) as usize, + height: u32::from(pixmap.height()) as usize, + scale, + } + } + + fn assert_color(&self, x_mm: f32, y_mm: f32, expected: [u8; 3]) { + let x = (x_mm * 72.0 / 25.4 * self.scale) as usize; + let y = self.height - 1 - (y_mm * 72.0 / 25.4 * self.scale) as usize; + let actual = &self.pixels[(y * self.width + x) * 4..][..3]; + assert!( + actual + .iter() + .zip(expected) + .all(|(&a, b)| a.abs_diff(b) < 25), + "at ({x_mm}, {y_mm}): expected {expected:?}, got {actual:?}" + ); + } +} + +fn image() -> PlotImage { + let corners = [ + [10.0, 10.0, 0.0], + [50.0, 10.0, 0.0], + [50.0, 50.0, 0.0], + [10.0, 50.0, 0.0], + ]; + let uv = [[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]]; + // Four distinct quadrants detect flips, reflections, and diagonal seams. + let pixels = (0..32) + .flat_map(|y| { + (0..32).flat_map(move |x| match (x < 16, y < 16) { + (true, true) => [255, 0, 0, 255], + (false, true) => [0, 0, 255, 255], + (true, false) => [0, 255, 0, 255], + (false, false) => [255, 255, 0, 255], + }) + }) + .collect(); + PlotImage { + image: ImageModel { + render_instance: None, + file_path: "synthetic".into(), + pixels: Arc::new(pixels), + width: 32, + height: 32, + opacity: 1.0, + corners, + corners_low: [[0.0; 3]; 4], + draw_depth: 0.5, + verts: [0, 1, 2, 0, 2, 3] + .into_iter() + .map(|i| ImageQuadVertex { + pos: corners[i], + pos_low: [0.0; 3], + uv: uv[i], + }) + .collect(), + }, + clips: Vec::new(), + } +} + +#[test] +fn embedded_ole_inside_insert_reaches_rendered_pdf() { + let scene = embedded_scene(); + let images = scene.paper_plot_images(); + assert_eq!(images.len(), 1); + assert_eq!((images[0].image.width, images[0].image.height), (64, 32)); + assert!(scene + .images + .values() + .any(|source| Arc::ptr_eq(&source.pixels, &images[0].image.pixels))); + let mut input = page(images); + input.scale = 10.0; + let raster = Raster::from_pdf(&export(&[input]), 0); + raster.assert_color(23.0, 42.0, [255, 0, 0]); + raster.assert_color(37.0, 48.0, [0, 0, 255]); + raster.assert_color(25.0, 47.5, [0, 255, 0]); + raster.assert_color(45.0, 45.0, [255, 255, 255]); +} + +#[test] +fn rotated_nonuniform_insert_preserves_pixel_orientation() { + let mut scene = embedded_scene(); + let handle = scene + .document + .entities() + .find_map(|e| match e { + EntityType::Insert(i) if i.block_name == "SyntheticImage" => Some(i.common.handle), + _ => None, + }) + .unwrap(); + let Some(EntityType::Insert(insert)) = scene.document.get_entity_mut(handle) else { + panic!() + }; + insert.insert_point = Vector3::new(60.0, 20.0, 0.0); + insert.rotation = std::f64::consts::FRAC_PI_2; + insert.set_x_scale(20.0); + insert.set_y_scale(10.0); + let raster = Raster::from_pdf(&export(&[page(scene.paper_plot_images())]), 0); + raster.assert_color(58.0, 26.0, [255, 0, 0]); + raster.assert_color(52.0, 54.0, [0, 0, 255]); + raster.assert_color(52.5, 30.0, [0, 255, 0]); + raster.assert_color(65.0, 40.0, [255, 255, 255]); +} + +#[test] +fn clipping_page_transform_and_shared_resources_survive_serialization() { + let mut first = image(); + // A visible-region triangle (IMAGE clip), plus inherited and viewport clips. + first.image.verts.truncate(3); + first + .clips + .push(vec![[15.0, 5.0], [55.0, 5.0], [55.0, 45.0], [15.0, 45.0]]); + first + .clips + .push(vec![[5.0, 15.0], [45.0, 15.0], [45.0, 55.0], [5.0, 55.0]]); + let mut second = page(vec![first.clone()]); + second.rotation_deg = 90; + second.scale = 0.5; + second.offset_x = 10.0; + second.offset_y = 10.0; + let bytes = export(&[page(vec![first]), second]); + let parsed = printpdf::PdfDocument::parse( + &bytes, + &printpdf::PdfParseOptions::default(), + &mut Vec::new(), + ) + .unwrap(); + let uses: Vec<_> = parsed + .pages + .iter() + .flat_map(|p| p.ops.iter()) + .filter_map(|op| { + if let printpdf::Op::UseXobject { id, .. } = op { + Some(id) + } else { + None + } + }) + .collect(); + assert_eq!( + uses.len(), + 2, + "one image draw per affine quad, without a diagonal seam" + ); + assert_eq!(uses[0], uses[1], "pages should share one image resource"); + let raster = Raster::from_pdf(&bytes, 0); + raster.assert_color(40.0, 20.0, [255, 255, 0]); + for (x, y) in [(12.0, 20.0), (48.0, 20.0), (20.0, 40.0), (40.0, 12.0)] { + raster.assert_color(x, y, [255, 255, 255]); + } + Raster::from_pdf(&bytes, 1).assert_color(85.0, 25.0, [255, 255, 0]); +} + +#[test] +fn image_alpha_and_plot_transparency_are_preserved() { + let mut plot = image(); + plot.image.opacity = 0.5; + // Intrinsic transparent texels remain transparent with either plot setting. + let pixels = Arc::make_mut(&mut plot.image.pixels); + for y in 16..32 { + for x in 0..16 { + pixels[(y * 32 + x) * 4 + 3] = 0; + } + } + let opaque = page(vec![plot.clone()]); + let mut transparent = page(vec![plot]); + transparent.options.transparency = true; + let bytes = export(&[opaque, transparent]); + let off = Raster::from_pdf(&bytes, 0); + off.assert_color(20.0, 40.0, [255, 0, 0]); + off.assert_color(20.0, 20.0, [255, 255, 255]); + let on = Raster::from_pdf(&bytes, 1); + on.assert_color(20.0, 40.0, [255, 128, 128]); + on.assert_color(20.0, 20.0, [255, 255, 255]); +} + +#[test] +fn depth_and_paper_model_groups_order_images_with_wipeouts_and_wires() { + let mut scene = Scene::new(); + let wipeout = Wipeout::polygonal( + &[ + Vector2::new(10.0, 30.0), + Vector2::new(30.0, 30.0), + Vector2::new(30.0, 50.0), + Vector2::new(10.0, 50.0), + ], + 0.0, + ); + scene.add_entity(EntityType::Wipeout(wipeout)); + let mut input = page(vec![image()]); + input.content.wipeouts = scene.paper_plot_wipeouts().as_ref().clone(); + assert_eq!(input.content.wipeouts.len(), 1); + input.content.wipeouts[0].draw_depth = 0.8; + let mut wire = WireModel::solid( + "over image".into(), + vec![[35.0, 15.0, 0.0], [45.0, 15.0, 0.0]], + [0.0, 0.0, 0.0, 1.0], + false, + ); + wire.line_weight_px = 8.0; + input.content.wires = Arc::new(vec![PlotWire { + wire, + draw_depth: 0.9, + }]); + let raster = Raster::from_pdf(&export(&[input]), 0); + raster.assert_color(20.0, 40.0, [255, 255, 255]); + raster.assert_color(40.0, 40.0, [0, 0, 255]); + raster.assert_color(40.0, 15.0, [0, 0, 0]); + + // First group wins by depth internally, second group still paints last. + let mut input = page(vec![image()]); + input.content.wipeouts = scene.paper_plot_wipeouts().as_ref().clone(); + input.content.wipeouts[0].draw_depth = 0.9; + input.content.group_splits = PlotGroupSplits { + wipeouts: 1, + ..Default::default() + }; + Raster::from_pdf(&export(&[input]), 0).assert_color(20.0, 40.0, [255, 0, 0]); +} + +#[test] +fn viewport_projection_clips_images_and_respects_frozen_and_nonplot_layers() { + let mut scene = embedded_scene(); + scene.set_current_layout("Model".into()); + scene.ensure_layer("Bitmap"); + let mut insert = Insert::new("SyntheticImage", Vector3::new(0.0, 0.0, 0.0)); + insert.common.layer = "Bitmap".into(); + insert.set_x_scale(20.0); + insert.set_y_scale(20.0); + scene.add_entity(EntityType::Insert(insert)); + scene.set_current_layout("Layout1".into()); + let mut viewport = Viewport::new(); + viewport.center = Vector3::new(50.0, 50.0, 0.0); + viewport.width = 20.0; + viewport.height = 20.0; + viewport.view_height = 20.0; + viewport.view_center = Vector3::new(20.0, 10.0, 0.0); + viewport.id = 2; + viewport.status.is_on = true; + let handle = scene.add_entity(EntityType::Viewport(viewport)); + let (_, _, _, images) = scene.viewport_plot_fills(); + assert_eq!(images.len(), 1); + let raster = Raster::from_pdf(&export(&[page(images)]), 0); + raster.assert_color(42.0, 43.0, [255, 0, 0]); + raster.assert_color(58.0, 57.0, [0, 0, 255]); + raster.assert_color(35.0, 45.0, [255, 255, 255]); + let layer = scene.document.layers.get("Bitmap").unwrap().handle; + let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(handle) else { + panic!() + }; + vp.frozen_layers.push(layer); + assert!(scene.viewport_plot_fills().3.is_empty()); + let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(handle) else { + panic!() + }; + vp.frozen_layers.clear(); + scene + .document + .layers + .get_mut("Bitmap") + .unwrap() + .is_plottable = false; + assert!(scene.viewport_plot_fills().3.is_empty()); +} + +#[test] +fn linked_image_uses_decoded_pixels_and_its_image_clip_without_reopening() { + use acadrust::entities::{ClipBoundary, RasterImage}; + let path = std::env::temp_dir().join(format!("ocs1266-linked-{}.png", std::process::id())); + let source = image(); + image::save_buffer(&path, &source.image.pixels, 32, 32, image::ColorType::Rgba8).unwrap(); + let mut raster = RasterImage::new( + path.to_str().unwrap(), + Vector3::new(10.0, 10.0, 0.0), + 32.0, + 32.0, + ); + raster.u_vector = Vector3::new(1.25, 0.0, 0.0); + raster.v_vector = Vector3::new(0.0, 1.25, 0.0); + raster.clipping_enabled = true; + raster.clip_boundary = + ClipBoundary::rectangular(Vector2::new(0.0, 0.0), Vector2::new(16.0, 32.0)); + let mut scene = Scene::new(); + let handle = scene.add_entity(EntityType::RasterImage(raster)); + scene.populate_images_from_document(); + std::fs::remove_file(path).unwrap(); + let images = scene.paper_plot_images(); + assert_eq!(images.len(), 1); + assert!(Arc::ptr_eq( + &images[0].image.pixels, + &scene.images[&handle].pixels + )); + let rendered = Raster::from_pdf(&export(&[page(images)]), 0); + rendered.assert_color(20.0, 40.0, [255, 0, 0]); + rendered.assert_color(20.0, 20.0, [0, 255, 0]); + rendered.assert_color(40.0, 40.0, [255, 255, 255]); +} + +#[test] +fn equal_sized_sources_remain_distinct_across_pages() { + let pages = { + let red = image(); + let mut cyan = image(); + Arc::make_mut(&mut cyan.image.pixels) + .chunks_exact_mut(4) + .for_each(|pixel| pixel.copy_from_slice(&[0, 255, 255, 255])); + assert!(!Arc::ptr_eq(&red.image.pixels, &cyan.image.pixels)); + vec![ + page(vec![red.clone()]), + page(vec![cyan.clone()]), + page(vec![red]), + page(vec![cyan]), + ] + }; + let bytes = export(&pages); + let parsed = printpdf::PdfDocument::parse( + &bytes, + &printpdf::PdfParseOptions::default(), + &mut Vec::new(), + ) + .unwrap(); + let uses: Vec<_> = parsed + .pages + .iter() + .flat_map(|page| &page.ops) + .filter_map(|op| { + if let printpdf::Op::UseXobject { id, .. } = op { + Some(id) + } else { + None + } + }) + .collect(); + assert_eq!(uses.len(), 4); + assert_ne!(uses[0], uses[1]); + assert_eq!(uses[0], uses[2]); + assert_eq!(uses[1], uses[3]); + for (index, expected) in [[255, 0, 0], [0, 255, 255], [255, 0, 0], [0, 255, 255]] + .into_iter() + .enumerate() + { + Raster::from_pdf(&bytes, index).assert_color(20.0, 40.0, expected); + } +} + +#[test] +fn affine_triangle_clip_has_no_shared_edge_seam() { + let mut plot = image(); + Arc::make_mut(&mut plot.image.pixels) + .chunks_exact_mut(4) + .for_each(|pixel| pixel.copy_from_slice(&[0, 0, 0, 128])); + let mut sheared = plot.clone(); + let transform = |[x, y, z]: [f32; 3]| [x + 0.27 * y + 5.0, 0.19 * x + y + 3.0, z]; + sheared + .image + .corners + .iter_mut() + .for_each(|p| *p = transform(*p)); + sheared + .image + .verts + .iter_mut() + .for_each(|v| v.pos = transform(v.pos)); + let pages = [page(vec![plot]), page(vec![sheared])]; + let bytes = export(&pages); + for (index, page) in pages.iter().enumerate() { + let raster = Raster::at_scale(&bytes, index, 8.0); + let corners = page.content.images[0].image.corners; + let u = [corners[1][0] - corners[0][0], corners[1][1] - corners[0][1]]; + let v = [corners[3][0] - corners[0][0], corners[3][1] - corners[0][1]]; + let determinant = u[0] * v[1] - u[1] * v[0]; + let mut checked = 0; + for y in 0..raster.height { + for x in 0..raster.width { + let dx = (x as f32 + 0.5) / raster.scale * 25.4 / 72.0 - corners[0][0]; + let dy = (raster.height as f32 - y as f32 - 0.5) / raster.scale * 25.4 / 72.0 + - corners[0][1]; + let a = (dx * v[1] - dy * v[0]) / determinant; + let b = (u[0] * dy - u[1] * dx) / determinant; + if (0.15..0.85).contains(&a) && (0.15..0.85).contains(&b) { + let rgb = &raster.pixels[(y * raster.width + x) * 4..][..3]; + assert!(rgb.iter().all(|&c| c.abs_diff(127) <= 2), + "page {index}, pixel ({x}, {y}): shared-edge gap or double coverage: {rgb:?}"); + checked += 1; + } + } + } + assert!(checked > 100_000); + } +} + +#[test] +fn malformed_bitmap_reports_an_error_without_replacing_the_pdf() { + let path = std::env::temp_dir().join(format!("ocs1266-invalid-{}.pdf", std::process::id())); + std::fs::write(&path, b"existing PDF").unwrap(); + let mut pixels = image(); + pixels.image.width += 1; + let mut coordinates = image(); + coordinates.image.corners[0][0] = f32::NAN; + let mut mapping = image(); + mapping.image.corners[2][0] += 10.0; + mapping + .image + .verts + .iter_mut() + .for_each(|vertex| vertex.uv = [0.0, 0.0]); + for (plot, reason) in [ + (pixels, "dimensions"), + (coordinates, "coordinates"), + (mapping, "texture mapping"), + ] { + let error = export_pdf(&page(vec![plot]), &path).unwrap_err(); + assert!( + error.contains("Page 1") && error.contains(reason), + "{error}" + ); + assert_eq!(std::fs::read(&path).unwrap(), b"existing PDF"); + } + std::fs::remove_file(path).unwrap(); +} diff --git a/tests/pdf_export_text_check.rs b/tests/pdf_export_text_check.rs index c15db1277..1efd0825a 100644 --- a/tests/pdf_export_text_check.rs +++ b/tests/pdf_export_text_check.rs @@ -11,7 +11,9 @@ use acadrust::entities::{Dimension, DimensionLinear, Text}; use acadrust::types::Vector3; use acadrust::EntityType; -use OpenCADStudio::io::pdf_export::{export_pdf, PdfPlotOptions, PlotWire}; +use OpenCADStudio::io::pdf_export::{ + export_pdf, PdfPageInput, PdfPlotOptions, PlotContent, PlotWire, +}; use OpenCADStudio::scene::Scene; #[test] @@ -60,49 +62,38 @@ fn text_and_dim_reach_pdf_export() { .collect(); let p_text = dir.join("with_text.pdf"); - export_pdf( - &plot_wires, - &[], - &[], - 210.0, - 297.0, - 0.0, - 0.0, - 0, - 1.0, - None, - &p_text, - None, - PdfPlotOptions::default(), - ) - .expect("export with text"); + let mut page = PdfPageInput { + content: PlotContent { + wires: std::sync::Arc::new(plot_wires), + ..Default::default() + }, + paper_w: 210.0, + paper_h: 297.0, + offset_x: 0.0, + offset_y: 0.0, + rotation_deg: 0, + scale: 1.0, + clip: None, + options: PdfPlotOptions::default(), + plot_style: None, + }; + export_pdf(&page, &p_text).expect("export with text"); let with_text = std::fs::read(&p_text).expect("read pdf"); assert!(with_text.starts_with(b"%PDF"), "not a PDF"); - let stripped: Vec<_> = plot_wires - .into_iter() + let stripped: Vec<_> = page + .content + .wires + .iter() + .cloned() .map(|mut w| { w.wire.text_verts.clear(); w }) .collect(); let p_bare = dir.join("no_text.pdf"); - export_pdf( - &stripped, - &[], - &[], - 210.0, - 297.0, - 0.0, - 0.0, - 0, - 1.0, - None, - &p_bare, - None, - PdfPlotOptions::default(), - ) - .expect("export without text"); + page.content.wires = std::sync::Arc::new(stripped); + export_pdf(&page, &p_bare).expect("export without text"); let no_text = std::fs::read(&p_bare).expect("read pdf"); assert!( From 743a25b76a9202adf7623c8524d81df0e8d27122 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:09:27 +0900 Subject: [PATCH 09/33] fix(plot-style): keep custom lineweights at full precision when saving Saving a plot style table wrote every value of its custom lineweight table with two decimals, so a pen such as 0.035 mm came back as 0.04 mm after a save and reload, and every color using it plotted with the wrong weight. Write the lineweights in full, through the same formatting the scale factor already uses, which keeps the decimal point on a whole value. Co-Authored-By: Claude Opus 5 --- src/io/plot_style.rs | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/io/plot_style.rs b/src/io/plot_style.rs index becceca44..89b2033c6 100644 --- a/src/io/plot_style.rs +++ b/src/io/plot_style.rs @@ -357,13 +357,8 @@ impl PlotStyleTable { let description = self.description.replace(['\r', '\n'], " "); s.push_str(&format!("description=\"{description}\n")); s.push_str("aci_table_available=TRUE\n"); - // The factor scales every lineweight when applied, so write it in full, - // keeping the decimal point on a whole value. - let mut scale_factor = self.scale_factor.to_string(); - if scale_factor.chars().all(|c| c.is_ascii_digit() || c == '-') { - scale_factor.push_str(".0"); - } - s.push_str(&format!("scale_factor={scale_factor}\n")); + // The factor scales every lineweight when applied, so write it in full. + s.push_str(&format!("scale_factor={}\n", full_real(self.scale_factor))); s.push_str(&format!( "apply_factor={}\n", if self.apply_factor { "TRUE" } else { "FALSE" } @@ -420,7 +415,7 @@ impl PlotStyleTable { } s.push_str("}\ncustom_lineweight_table{\n"); for (index, weight) in self.lineweights.iter().enumerate() { - s.push_str(&format!(" {index}={weight:.2}\n")); + s.push_str(&format!(" {index}={}\n", full_real(*weight))); } s.push_str("}\n"); s @@ -512,6 +507,15 @@ fn adler32(bytes: &[u8]) -> u32 { (b << 16) | a } +/// A real number written in full, keeping the decimal point on a whole value. +fn full_real(value: f32) -> String { + let mut text = value.to_string(); + if text.chars().all(|c| c.is_ascii_digit() || c == '-') { + text.push_str(".0"); + } + text +} + fn packed_rgb([r, g, b]: [u8; 3]) -> i32 { u32::from_be_bytes([0xC2, r, g, b]) as i32 } @@ -780,4 +784,16 @@ mod round_trip_tests { assert!(table.to_text().contains("\nscale_factor=1.0\n")); assert_eq!(saved_and_reloaded(&table).scale_factor, 1.0); } + + /// Saving keeps each custom lineweight as the table holds it. They used to + /// be cut to two decimals, so a 0.035 mm pen came back as a 0.04 mm one. + #[test] + fn custom_lineweights_survive_a_save() { + let mut table = PlotStyleTable::identity("pens.ctb"); + table.lineweights[1] = 0.035; + table.aci_entries[1].lineweight = 1; + let reloaded = saved_and_reloaded(&table); + assert_eq!(reloaded.lineweights, table.lineweights); + assert_eq!(reloaded.resolve_lineweight(1), Some(0.035)); + } } From e16ab7c86f439a56cef7a1f574937c8b75483dbf Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Mon, 14 Sep 2026 17:09:33 -0500 Subject: [PATCH 10/33] Preserve narrow spline clearances when tessellating solid fills Recover failed small spline boundaries with the kernel chord-error sampler, using the same coordinate frame as the fill mesh. Preserve source curves and enforce recovery and total hatch vertex limits. Fixes #1268 --- src/scene/entity.rs | 35 ++++- src/scene/hatch_boundary.rs | 167 ++++++++++++++++++++ src/scene/hatch_boundary/tests.rs | 246 ++++++++++++++++++++++++++++++ src/scene/mod.rs | 1 + 4 files changed, 442 insertions(+), 7 deletions(-) create mode 100644 src/scene/hatch_boundary.rs create mode 100644 src/scene/hatch_boundary/tests.rs diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 26d122d7f..a74c5f0ac 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1870,7 +1870,7 @@ impl Scene { .map(|point| to_xy(point[0], point[1])) .collect(); rings.push(ring); - local_rings.push(local_ring); + local_rings.push((local_ring, path)); ring_sources.push(path.boundary_handles.clone()); } @@ -1883,7 +1883,8 @@ impl Scene { let mut local_boundary = Vec::new(); let mut boundary_exterior = Vec::new(); let mut boundary_sources = Vec::new(); - for (((ring, local_ring), sources), depth) in rings + let mut spline_paths = Vec::new(); + for (((ring, (local_ring, path)), sources), depth) in rings .into_iter() .zip(local_rings) .zip(ring_sources) @@ -1901,12 +1902,14 @@ impl Scene { boundary.push([f64::NAN, f64::NAN]); local_boundary.push([f32::NAN, f32::NAN]); } + let range = boundary.len()..boundary.len() + ring.len(); boundary.extend(ring); - local_boundary.extend( - local_ring - .into_iter() - .map(|[x, y]| [x as f32, y as f32]), - ); + local_boundary.extend(local_ring.iter().map(|&[x, y]| [x as f32, y as f32])); + if path.edges.iter().any(|edge| { + matches!(edge, acadrust::entities::BoundaryEdge::Spline(_)) + }) { + spline_paths.push((path, local_ring, range)); + } boundary_exterior.push(depth == 0); boundary_sources.push(sources); } @@ -2095,6 +2098,24 @@ impl Scene { } } } + let project = |point: [f64; 2]| { + let [x, y] = to_xy(point[0], point[1]); + [(x - world_origin[0]) as f32 as f64, (y - world_origin[1]) as f32 as f64] + }; + for (path, ring, range) in spline_paths.into_iter().rev() { + if range.end > boundary.len() { + continue; + } + let curves = || path.edges.iter().filter_map(crate::entities::hatch::edge_curve).collect(); + if let Some(refined) = super::hatch_boundary::refine(&ring, curves, &project) { + if boundary.len() - range.len() + refined.len() > MAX_HATCH_MODEL_VERTS { + continue; + } + boundary.splice(range.clone(), refined.iter().map(|p| to_xy(p[0], p[1]))); + local_boundary.splice(range, refined.into_iter().map(|[x, y]| [x as f32, y as f32])); + } + } + let boundary_f32: Vec<[f32; 2]> = boundary .iter() .map(|&[x, y]| { diff --git a/src/scene/hatch_boundary.rs b/src/scene/hatch_boundary.rs new file mode 100644 index 000000000..daec2f0a4 --- /dev/null +++ b/src/scene/hatch_boundary.rs @@ -0,0 +1,167 @@ +use cadkernel::geom2d::{triangulate_rings, Curve}; +use cadkernel::tessellation::DEFAULT_ANGLE; + +#[cfg(test)] +mod tests; + +const MAX_RECOVERY_POINTS: usize = 512; +const MAX_EXTRA_POINTS: usize = 128; +const MAX_PASSES: usize = 16; +const MAX_INTERSECTION_CHECKS: usize = 1_000_000; + +/// Resample a crossed spline boundary without changing ordinary boundaries. +/// `project` must use the same coordinate frame as the eventual fill mesh. +pub(super) fn refine( + ring: &[[f64; 2]], + curves: impl FnOnce() -> Vec, + project: impl Fn([f64; 2]) -> [f64; 2], +) -> Option> { + if ring.len() > MAX_RECOVERY_POINTS { + return None; + } + let mut work_left = MAX_INTERSECTION_CHECKS; + if !crossings(&[ring], &project, &mut work_left)?[0] { + return None; + } + let projected = ring.iter().copied().map(&project).collect(); + if !triangulate_rings(&[projected]).1.is_empty() { + return None; + } + let limit = (ring.len() + MAX_EXTRA_POINTS).min(MAX_RECOVERY_POINTS); + let mut spans = Vec::new(); + for curve in curves() { + if let Curve::Nurbs(spline) = curve { + let (start, end) = spline.domain(); + if end <= start || spline.control_points().len() > limit { + return None; + } + for pair in spline.knots().windows(2) { + if pair[0] >= start && pair[1] <= end && pair[0] < pair[1] { + spans.push(Curve::Nurbs(spline.trimmed( + (pair[0] - start) / (end - start), + (pair[1] - start) / (end - start), + )?)); + } + } + } else { + spans.push(curve); + } + } + let mut remaining = limit; + let mut points: Vec<_> = spans + .iter() + .map(|curve| sample(curve, &mut remaining)) + .collect::>()?; + for _ in 0..MAX_PASSES { + let marked = crossings(&points, &project, &mut work_left)?; + if !marked.iter().any(|marked| *marked) { + let ring = super::entity::chain_path_edges(points); + let projected = ring.iter().copied().map(&project).collect(); + return (!triangulate_rings(&[projected]).1.is_empty()).then_some(ring); + } + let mut next = Vec::new(); + let mut sampled = Vec::new(); + let mut changed = false; + let mut remaining = limit; + for ((curve, points), marked) in spans.into_iter().zip(points).zip(marked) { + if marked { + if let Curve::Nurbs(spline) = &curve { + let (a, b) = spline.split_at(0.5)?; + for part in [Curve::Nurbs(a), Curve::Nurbs(b)] { + sampled.push(sample(&part, &mut remaining)?); + next.push(part); + } + changed = true; + continue; + } + } + remaining = remaining.checked_sub(points.len())?; + next.push(curve); + sampled.push(points); + } + if !changed { + return None; + } + spans = next; + points = sampled; + } + None +} + +fn sample(curve: &Curve, remaining: &mut usize) -> Option> { + let Curve::Nurbs(spline) = curve else { + let points = curve.tessellate_angle(DEFAULT_ANGLE); + *remaining = remaining.checked_sub(points.len())?; + return Some(points); + }; + let mut min = [f64::INFINITY; 2]; + let mut max = [f64::NEG_INFINITY; 2]; + for point in spline.control_points() { + for axis in 0..2 { + min[axis] = min[axis].min(point[axis]); + max[axis] = max[axis].max(point[axis]); + } + } + // Scale chord error to this span; splitting tightens only crossed spans. + let tolerance = (max[0] - min[0]).hypot(max[1] - min[1]) * (1.0 - (DEFAULT_ANGLE * 0.5).cos()); + if !tolerance.is_finite() || tolerance <= 0.0 { + return None; + } + let points = curve.tessellate_within(tolerance); + *remaining = remaining.checked_sub(points.len())?; + Some(points) +} + +fn crossings( + edges: &[impl AsRef<[[f64; 2]]>], + project: &impl Fn([f64; 2]) -> [f64; 2], + work_left: &mut usize, +) -> Option> { + let mut marked = vec![false; edges.len()]; + for projected in [false, true] { + let mut segments = Vec::new(); + for (edge, points) in edges.iter().enumerate() { + for pair in points.as_ref().windows(2) { + let [a, b] = [pair[0], pair[1]].map(|p| if projected { project(p) } else { p }); + if !a.into_iter().chain(b).all(f64::is_finite) { + return None; + } + segments.push((a[0].min(b[0]), a[0].max(b[0]), edge, a, b)); + } + } + segments.sort_unstable_by(|a, b| a.0.total_cmp(&b.0)); + for (index, &(_, right, edge, a, b)) in segments.iter().enumerate() { + for &(left, _, other, c, d) in &segments[index + 1..] { + if left > right { + break; + } + *work_left = work_left.checked_sub(1)?; + if a[1].max(b[1]) < c[1].min(d[1]) || c[1].max(d[1]) < a[1].min(b[1]) { + continue; + } + let cross = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| { + (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) + }; + let opposite = |a: f64, b: f64| (a < 0.0 && b > 0.0) || (a > 0.0 && b < 0.0); + let inside = |p: [f64; 2], a: [f64; 2], b: [f64; 2]| { + cross(a, b, p) == 0.0 + && (0..2).any(|axis| { + p[axis] > a[axis].min(b[axis]) && p[axis] < a[axis].max(b[axis]) + }) + }; + if opposite(cross(a, b, c), cross(a, b, d)) + && opposite(cross(c, d, a), cross(c, d, b)) + || (inside(a, c, d) + || inside(b, c, d) + || inside(c, a, b) + || inside(d, a, b) + || a != b && (a == c && b == d || a == d && b == c)) + { + marked[edge] = true; + marked[other] = true; + } + } + } + } + Some(marked) +} diff --git a/src/scene/hatch_boundary/tests.rs b/src/scene/hatch_boundary/tests.rs new file mode 100644 index 000000000..175ce7a92 --- /dev/null +++ b/src/scene/hatch_boundary/tests.rs @@ -0,0 +1,246 @@ +use super::*; +use crate::scene::Scene; +use acadrust::entities::hatch::{BoundaryEdge, BoundaryPath, Hatch, PolylineEdge, SplineEdge}; +use acadrust::types::{Vector2, Vector3}; +use cadkernel::geom2d::NurbsCurve; + +fn narrow(clearance: f64, offset: [f64; 2]) -> NurbsCurve { + let line = |a: [f64; 2], b: [f64; 2]| { + [ + a, + std::array::from_fn(|i| a[i] + (b[i] - a[i]) / 3.0), + std::array::from_fn(|i| a[i] + 2.0 * (b[i] - a[i]) / 3.0), + b, + ] + }; + let [a, b, c, d, e, f] = [ + [-1.0, -1.0], + [0.0, -1.0], + [0.0, 1.0], + [clearance, 0.999], + [-0.5, 1.5], + [-1.0, 1.5], + ]; + // The returning cubic passes close to the vertical side b--c. + let spans = [ + line(a, b), + line(b, c), + line(c, d), + [d, [clearance, 1.1], [-0.3, 1.5], e], + line(e, f), + line(f, a), + ]; + let mut controls = vec![a]; + for span in spans { + controls.extend_from_slice(&span[1..]); + } + for p in &mut controls { + for i in 0..2 { + p[i] += offset[i]; + } + } + let mut knots = vec![0.0; 4]; + for i in 1..6 { + knots.extend([i as f64; 3]); + } + knots.extend([6.0; 4]); + NurbsCurve::new(3, controls, knots, None).unwrap() +} + +fn edge(spline: NurbsCurve) -> BoundaryEdge { + BoundaryEdge::Spline(SplineEdge { + degree: spline.degree() as i32, + rational: false, + periodic: false, + knots: spline.knots().to_vec(), + control_points: spline + .control_points() + .iter() + .map(|p| Vector3::new(p[0], p[1], 0.0)) + .collect(), + fit_points: Vec::new(), + start_tangent: Vector2::new(0.0, 0.0), + end_tangent: Vector2::new(0.0, 0.0), + }) +} + +fn rectangle(a: [f64; 2], b: [f64; 2], offset: [f64; 2]) -> BoundaryPath { + let points = [a, [b[0], a[1]], b, [a[0], b[1]]] + .map(|p| Vector2::new(p[0] + offset[0], p[1] + offset[1])); + let mut path = BoundaryPath::new(); + path.add_edge(BoundaryEdge::Polyline(PolylineEdge::new( + points.to_vec(), + true, + ))); + path +} + +#[test] +fn narrow_fill_preserves_corners_holes_and_shared_origin() { + let curve = narrow(0.00001, [0.0; 2]); + let area: f64 = (0..6) + .map(|i| { + Curve::Nurbs(curve.trimmed(i as f64 / 6.0, (i + 1) as f64 / 6.0).unwrap()) + .enclosed_area() + }) + .sum(); + for (offset, shuffled, multiple) in [ + ([0.0; 2], false, false), + ([1e6, -1e6], false, false), + ([0.0; 2], true, true), + ([1e6, -1e6], true, true), + ] { + let spline = narrow(0.00001, offset); + let mut path = BoundaryPath::new(); + path.edges = if shuffled { + let (a, b) = spline.split_at(0.5).unwrap(); + vec![edge(b), edge(a.reversed())] + } else { + vec![edge(spline.clone())] + }; + let mut hatch = Hatch::new(); + hatch.is_solid = true; + hatch.paths.push(path); + if multiple { + hatch.paths.extend([ + rectangle([-0.8, -0.8], [-0.2, -0.2], offset), + rectangle([-0.6, -0.6], [-0.4, -0.4], offset), + rectangle([1.0, -1.0], [2.0, 2.5], offset), + ]); + } + let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap(); + if multiple { + assert_eq!(model.world_origin, [offset[0] + 0.5, offset[1] + 0.75]); + } + let project = |p: [f64; 2]| { + [ + (p[0] - model.world_origin[0]) as f32, + (p[1] - model.world_origin[1]) as f32, + ] + }; + let original = Curve::Nurbs(spline.clone()).tessellate_angle(DEFAULT_ANGLE); + let original: Vec<_> = original + .into_iter() + .map(|p| project(p).map(f64::from)) + .collect(); + assert!(triangulate_rings(&[original]).1.is_empty()); + for i in 0..=6 { + assert!(model + .boundary + .contains(&project(spline.point_at(i as f64 / 6.0)))); + } + assert_eq!(model.boundary_paths.as_deref().unwrap(), &hatch.paths); + let (vertices, indices) = model.fill_mesh(); + assert!(!indices.is_empty()); + let mut filled_area = 0.0; + for triangle in indices.chunks_exact(3) { + let [a, b, c] = [triangle[0], triangle[1], triangle[2]] + .map(|i| vertices[i as usize].map(f64::from)); + let twice_area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); + assert!(twice_area > 0.0); + filled_area += twice_area * 0.5; + if multiple { + let p = + [0, 1].map(|i| (a[i] + b[i] + c[i]) / 3.0 + model.world_origin[i] - offset[i]); + let inside = |lo, hi| p.iter().all(|&v| v > lo && v < hi); + assert!(!inside(-0.8, -0.2) || inside(-0.6, -0.4)); + } + } + let expected = area + if multiple { 3.5 - 0.36 + 0.04 } else { 0.0 }; + assert!( + (filled_area - expected).abs() < 0.001, + "{filled_area} vs {expected}" + ); + } +} + +#[test] +fn valid_samples_are_unchanged_and_invalid_samples_stay_rejected() { + for clearance in [0.01, 0.000001, 0.000002, 0.000005] { + let spline = narrow(clearance, [0.0; 2]); + let original = Curve::Nurbs(spline.clone()).tessellate_angle(DEFAULT_ANGLE); + let mut path = BoundaryPath::new(); + path.edges.push(edge(spline)); + let mut hatch = Hatch::new(); + hatch.is_solid = true; + hatch.paths.push(path); + let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap(); + let expected: Vec<_> = original + .iter() + .map(|p| { + [ + (p[0] - model.world_origin[0]) as f32, + (p[1] - model.world_origin[1]) as f32, + ] + }) + .collect(); + assert_eq!(model.boundary.as_ref(), &expected); + assert_eq!(model.fill_mesh().1.is_empty(), clearance < 0.01); + } + let curve = Curve::Nurbs(narrow(0.01, [0.0; 2])); + assert!(refine( + &curve.tessellate_angle(DEFAULT_ANGLE), + || panic!("valid path resampled"), + |p| p + ) + .is_none()); + assert!(refine( + &vec![[0.0; 2]; MAX_RECOVERY_POINTS + 1], + || panic!("size cap ignored"), + |p| p + ) + .is_none()); +} + +#[test] +fn contacts_and_intersection_work_are_bounded() { + for (a, b, expected) in [ + ([[0.0, 0.0], [1.0, 1.0]], [[1.0, 1.0], [2.0, 0.0]], false), + ([[0.0, 0.0], [2.0, 0.0]], [[1.0, 0.0], [1.0, 1.0]], true), + ([[0.0, 0.0], [2.0, 0.0]], [[1.0, 0.0], [3.0, 0.0]], true), + ] { + assert_eq!(crossings(&[a, b], &|p| p, &mut 10).unwrap(), [expected; 2]); + } + let edges = vec![[[0.0, 0.0], [1.0, 1.0]]; 100]; + let mut budget = 7; + assert!(crossings(&edges, &|p| p, &mut budget).is_none()); + assert_eq!(budget, 0); +} + +#[test] +fn recovery_respects_the_total_hatch_vertex_cap() { + let curve = Curve::Nurbs(narrow(0.00001, [0.0; 2])); + let original = curve.tessellate_angle(DEFAULT_ANGLE); + let Curve::Nurbs(spline) = curve.clone() else { + unreachable!() + }; + let mut path = BoundaryPath::new(); + path.edges.push(edge(spline)); + let mut hatch = Hatch::new(); + hatch.is_solid = true; + hatch.paths.push(path); + let count = 16_384 - original.len() - 2; + let points = (0..count) + .map(|i| { + let angle = std::f64::consts::TAU * i as f64 / count as f64; + Vector2::new(1.5 + 0.5 * angle.cos(), 0.75 + 1.75 * angle.sin()) + }) + .collect(); + let mut other = BoundaryPath::new(); + other.add_edge(BoundaryEdge::Polyline(PolylineEdge::new(points, true))); + hatch.paths.push(other); + let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap(); + let project = |p: [f64; 2]| { + [ + (p[0] - model.world_origin[0]) as f32 as f64, + (p[1] - model.world_origin[1]) as f32 as f64, + ] + }; + assert!(refine(&original, || vec![curve], project).unwrap().len() > original.len()); + assert_eq!(model.boundary.len(), 16_384); + let expected: Vec<_> = original + .into_iter() + .map(|p| project(p).map(|v| v as f32)) + .collect(); + assert_eq!(&model.boundary[..expected.len()], expected.as_slice()); +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d36fef59f..d2b43361e 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -33,6 +33,7 @@ pub(crate) mod dimension_assoc; pub(crate) mod centermark; mod dwg_native_constraints; mod entity; +mod hatch_boundary; mod group_layer; mod layout; mod limits; From 59ae38ae1a15f2a29558f9c1b3cc4cf8eebb2df8 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Tue, 15 Sep 2026 00:31:41 +0200 Subject: [PATCH 11/33] Add per-constraint delete; fix named-parameter reference bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constraint delete: a Properties-panel row delete button, viewport click-to-select on a constraint glyph pill with Delete-key support, and a right-click "Delete Constraint" menu item — all routed through one undoable Scene::selected_constraint-aware path. Fixes two named-parameter bugs found while testing that feature: - DIMCONSTRAINT: a parameter named like a mode-switch keyword ("r", "d", "a", "x", "y") could never be referenced, since the keyword check ran before the parameter-name check. - PARAMETERS modal Apply rebuilt Scene::named_parameters but never refreshed the Properties panel, leaving renamed/deleted rows stale until an unrelated refresh (e.g. a selection change) happened to occur. Co-Authored-By: Claude Sonnet 5 --- src/app/command_driver.rs | 72 ++++++++++++++++++++++++++ src/app/mod.rs | 6 +++ src/app/properties.rs | 2 +- src/app/update/mod.rs | 11 ++++ src/app/update/viewport.rs | 26 ++++++++++ src/app/view/mod.rs | 76 +++++---------------------- src/app/view/overlay.rs | 10 ++++ src/modules/parametric/value.rs | 47 +++++++++++++++++ src/scene/mod.rs | 8 +++ src/scene/model/object.rs | 9 +++- src/scene/selection.rs | 4 ++ src/scene/sketch_constraints.rs | 92 +++++++++++++++++++++++++++++++++ src/ui/overlay.rs | 61 +++++++++++++++++++--- src/ui/properties.rs | 23 +++++++-- 14 files changed, 370 insertions(+), 77 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index af720ea76..58a71c3c3 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -970,6 +970,41 @@ impl OpenCADStudio { } } + /// Removes one persistent sketch constraint by id (undoable), regardless + /// of whether it's flagged as conflicting. Shared by the Properties + /// panel's per-row delete button, the viewport's Delete key when a + /// constraint glyph is selected, and its right-click "Delete Constraint" + /// menu item — see `resolve_one_sketch_conflict` above for the same + /// undo pattern applied to the solver's own auto-removal. + pub(super) fn delete_sketch_constraint( + &mut self, + id: crate::scene::sketch_constraints::ConstraintId, + ) { + let i = self.active_tab; + let scope = self.tabs[i].current_sketch_scope(); + let Some(set) = self.tabs[i].scene.sketch_constraint_set(scope) else { return }; + let Some(constraint) = set.get(id) else { return }; + let touched: Vec = constraint.refs.iter().map(|r| r.entity).collect(); + let label = "Delete constraint"; + + let constraints_before = set.clone(); + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_sketch_constraints_before(scope, constraints_before); + self.tabs[i].scene.sketch_constraint_set_mut(scope).remove(id); + if self.tabs[i].scene.selected_constraint == Some(id) { + self.tabs[i].scene.selected_constraint = None; + } + let changes: Vec<(Handle, crate::scene::ChangeKind)> = + touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); + self.tabs[i].scene.bump_entities(&changes); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + self.refresh_properties(); + } + /// Rebuilds the active tab's `Scene::named_parameters` from the /// editor's working buffer and re-solves dependent constraints. pub(super) fn apply_named_parameter_editor_rows(&mut self) { @@ -7455,6 +7490,43 @@ mod sketch_constraint_undo_tests { assert_eq!(set.constraints.len(), 2, "undo should restore the removed constraint"); } + /// `delete_sketch_constraint` is the non-conflict counterpart to + /// `resolve_one_sketch_conflict` above: it removes an arbitrary + /// constraint by id (undoable), and also clears + /// `Scene::selected_constraint` when the deleted id was the one + /// selected — otherwise a stale id would linger and the viewport's + /// Delete key / right-click menu would target a constraint that no + /// longer exists. + #[test] + fn delete_sketch_constraint_removes_an_arbitrary_constraint_and_is_undoable() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0); + + let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Horizontal, + refs: vec![SketchRef::whole(line)], + driving_param: None, + label: "Horizontal constraint", + }); + let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap(); + assert_eq!(set.constraints.len(), 1); + let id = set.constraints[0].id; + app.tabs[app.active_tab].scene.selected_constraint = Some(id); + + app.delete_sketch_constraint(id); + let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap(); + assert_eq!(set.constraints.len(), 0, "the constraint should have been removed"); + assert_eq!( + app.tabs[app.active_tab].scene.selected_constraint, None, + "deleting the selected constraint should clear the selection" + ); + + app.undo_steps(1); + let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap(); + assert_eq!(set.constraints.len(), 1, "undo should restore the deleted constraint"); + } + /// `named_parameters_design.md` stage 4: applying the PARAMETERS editor's /// working buffer both defines the parameter and re-solves whatever /// constraint already references it by name — the entire point of a diff --git a/src/app/mod.rs b/src/app/mod.rs index 0ad215b09..ea624615f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2908,6 +2908,12 @@ pub enum Message { /// A Constraints-section row was clicked: select every entity in the /// list (replacing the current selection) in the viewport. PropConstraintLinkClick(Vec), + /// A Constraints-section row's delete button was clicked, the Delete key + /// was pressed with the constraint's viewport glyph selected, or its + /// right-click "Delete Constraint" menu item was chosen: remove that one + /// persistent sketch constraint (undoable), independent of the entities + /// it touches. + PropConstraintDelete(crate::scene::sketch_constraints::ConstraintId), // ── About window ──────────────────────────────────────────────────── AboutOpen, // ── Graphics warning ──────────────────────────────────────────────── diff --git a/src/app/properties.rs b/src/app/properties.rs index 7096e7b03..2f3223b0f 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2224,7 +2224,7 @@ impl OpenCADStudio { crate::scene::model::object::Property { label, field: "sketch_constraint", - value: crate::scene::model::object::PropValue::EntityLink { handles, conflicting }, + value: crate::scene::model::object::PropValue::EntityLink { id: c.id, handles, conflicting }, } }) .collect(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9cfbe5769..578090a8e 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4287,6 +4287,13 @@ impl OpenCADStudio { } let i = self.active_tab; self.tabs[i].scene.selection.borrow_mut().context_menu = None; + // A selected constraint-glyph pill takes Delete before entity + // erase — the two selections are mutually exclusive (see + // `Scene::selected_constraint`). + if let Some(id) = self.tabs[i].scene.selected_constraint { + self.delete_sketch_constraint(id); + return Task::none(); + } let handles: Vec<_> = self.tabs[i].scene.selected.iter().cloned().collect(); if !handles.is_empty() { // Erase is delta-safe unless a target is in a group (group @@ -6218,6 +6225,10 @@ impl OpenCADStudio { self.refresh_properties(); Task::none() } + Message::PropConstraintDelete(id) => { + self.delete_sketch_constraint(id); + Task::none() + } // ── Options / About windows ─────────────────────────────────── Message::OptionsOpen => { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c57bb947c..58146a792 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4451,6 +4451,31 @@ properties={:.1}ms picked={}", sel.box_current = None; } else { if box_anchor.is_none() { + // A sketch-constraint glyph pill draws on top of the + // geometry it touches and takes priority over it when + // clicked (design mirrors `has_selection` gating the + // viewport right-click menu, and `DeleteSelected` + // preferring a selected pill over entity erase). + let glyph_hit = (self.tabs[i].scene.current_layout == "Model") + .then(|| { + let scope = self.tabs[i].current_sketch_scope(); + self.tabs[i].scene.constraint_glyph_hit( + scope, + canvas_sz, + self.show_constraint_values, + p_full, + ) + }) + .flatten(); + if let Some(id) = glyph_hit { + if !selection_pick_add && !self.shift_down { + self.tabs[i].scene.deselect_all(); + } + self.tabs[i].scene.selected_constraint = Some(id); + self.refresh_properties(); + selection_just_completed = true; + } + if glyph_hit.is_none() { let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); let click_world = self.cursor_model_point(i, &edit_cam, p, bounds); let t_arm = crate::perf::enabled().then(Instant::now); @@ -4611,6 +4636,7 @@ was_selected={}", } } } + } } else { let a = box_anchor.unwrap(); let box_points = [ diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 285905fcb..2245b295f 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -817,72 +817,21 @@ bg={bg_ms:.1}ms n={view_count}" let point_cursor = tab.active_cmd.as_ref().is_some_and(|cmd| { !cmd.needs_entity_pick() && !cmd.is_selection_gathering() }); - // Constraint glyphs are displayed in model space only. - let constraint_glyphs: Vec<(iced::Point, [f32; 2], String, bool)> = if is_paper { + // Persistent sketch-constraint glyphs (design doc §6.3/§7) — model + // space only, like the constraints themselves; paper space shows + // none (out of scope per the design doc §3.1). + let constraint_glyphs: Vec<(iced::Point, [f32; 2], String, bool, bool)> = if is_paper { Vec::new() } else { - let (vw, vh) = sel_ref.vp_size; let scope = tab.current_sketch_scope(); - match tab.scene.sketch_constraint_set(scope) { - Some(set) if !set.constraints.is_empty() => { - let edit_frame = tab.scene.viewport_edit_frame((vw, vh)); - let bounds = match &edit_frame { - Some((_, full)) => *full, - None => tab.scene.active_model_tile_bounds(vw, vh), - }; - let (view_rot, eye) = if let Some((cam, _)) = &edit_frame { - (cam.view_proj_rte(bounds), cam.eye()) - } else { - let cam = tab.scene.camera.borrow(); - (cam.view_proj_rte(bounds), cam.eye()) - }; - set.constraints - .iter() - .filter(|c| c.enabled) - .filter(|c| tab.scene.is_sketch_constraint_visible(scope, c.id)) - .filter_map(|c| { - let (anchor, outward) = - crate::scene::sketch_constraints::glyph_placement( - &tab.scene.document, - c, - )?; - let screen = crate::scene::pick::grip::project_rte( - glam::DVec3::new(anchor.x, anchor.y, anchor.z), - view_rot, - eye, - bounds, - )?; - let outward_screen = crate::scene::pick::grip::project_rte( - glam::DVec3::new( - anchor.x + outward.x, - anchor.y + outward.y, - anchor.z + outward.z, - ), - view_rot, - eye, - bounds, - )?; - let direction = (outward_screen - screen) - .normalize_or(glam::Vec2::NEG_Y); - let point = iced::Point::new( - bounds.x + screen.x, - bounds.y + screen.y, - ); - let is_conflicting = - set.conflicts.iter().any(|(id, _)| *id == c.id); - let label = if self.show_constraint_values { - crate::scene::sketch_constraints::glyph_label(c) - } else { - c.kind.glyph_symbol().to_string() - }; - point.x.is_finite().then(|| { - (point, direction.to_array(), label, is_conflicting) - }) - }) - .collect() - } - _ => Vec::new(), - } + tab.scene + .constraint_glyph_placements_screen(scope, sel_ref.vp_size, self.show_constraint_values) + .into_iter() + .map(|(id, point, direction, label, is_conflicting)| { + let selected = tab.scene.selected_constraint == Some(id); + (point, direction, label, is_conflicting, selected) + }) + .collect() }; crate::ui::overlay::selection_overlay( std::sync::Arc::clone(&tab.scene.selection), @@ -1811,6 +1760,7 @@ bg={bg_ms:.1}ms n={view_count}" command_line_inset, has_cmd, has_selection, + tab.scene.selected_constraint, isolation_active, last_cmds, draworder_open, diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index 3192b9128..275097766 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -1114,6 +1114,7 @@ pub(super) fn viewport_context_menu_overlay( bottom_inset: f32, has_cmd: bool, has_selection: bool, + selected_constraint: Option, isolation_active: bool, last_cmds: Vec, draworder_open: bool, @@ -1250,6 +1251,15 @@ pub(super) fn viewport_context_menu_overlay( t!("Invert Selection").into_owned(), Message::InvertSelection, )); + } else if let Some(id) = selected_constraint { + // A selected constraint-glyph pill gets its own minimal menu — + // none of the entity-selection actions above (Move/Copy/Isolate/ + // Select Similar) apply to it. + items.push(item( + t!("Delete Constraint").into_owned(), + Message::PropConstraintDelete(id), + )); + items.push(sep()); } if isolation_active { items.push(item( diff --git a/src/modules/parametric/value.rs b/src/modules/parametric/value.rs index 068923701..7b16e4c13 100644 --- a/src/modules/parametric/value.rs +++ b/src/modules/parametric/value.rs @@ -302,6 +302,18 @@ impl CadCommand for DistanceConstraintCommand { } fn on_text_input(&mut self, text: &str) -> Option { + // A token that names an existing parameter always wins over the + // mode-switch keywords below — otherwise a parameter literally + // named "r", "d", "a", "x", or "y" (all entirely reasonable names, + // and exactly the single-letter keywords this command recognizes) + // could never be referenced here: e.g. on a circle, typing "r" + // would always be swallowed as the Radius mode-switch instead of + // resolving to the parameter. + if let Some(value @ DrivingValue::Named(_)) = + parse_driving_value(text, &self.known_param_names) + { + return self.build(value); + } // Mode-switch keywords re-prompt instead of building — consumed // inputs return `Some(NeedPoint)` so the driver doesn't re-offer // the same token a second time (matches e.g. `trim.rs`'s @@ -584,6 +596,41 @@ mod tests { )); } + /// A parameter literally named "r" (or "d"/"a"/"x"/"y", the other + /// single-letter mode-switch keywords) must still resolve to the + /// parameter, not be swallowed as a keyword — regression test for the + /// bug where typing a circle's radius-driving parameter named "r" was + /// always misread as the "switch to Radius mode" keyword and silently + /// re-prompted instead of building a constraint. + #[test] + fn a_parameter_named_like_a_mode_keyword_still_resolves_by_name() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene); + scene.named_parameters_mut().set("r", "5").unwrap(); + let mut cmd = DistanceConstraintCommand::new(&scene, circle).unwrap(); + assert!(matches!( + cmd.on_text_input("R"), + Some(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Radius, + driving_param: Some(DrivingValue::Named(name)), + .. + }) if name == "r" + )); + + let mut scene2 = Scene::new(); + let line = add_line(&mut scene2); + scene2.named_parameters_mut().set("x", "5").unwrap(); + let mut cmd2 = DistanceConstraintCommand::new(&scene2, line).unwrap(); + assert!(matches!( + cmd2.on_text_input("X"), + Some(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Distance, + driving_param: Some(DrivingValue::Named(name)), + .. + }) if name == "x" + )); + } + #[test] fn ordinary_distances_reject_nonpositive_and_nonfinite_values() { let mut scene = Scene::new(); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d36fef59f..42961d23e 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1627,6 +1627,13 @@ pub struct Scene { /// Currently selected entity handles. pub selected: HashSet, selected_order: Vec, + /// The sketch-constraint glyph pill currently selected in the viewport + /// (click-to-select — see `sketch_constraints::constraint_glyph_hit`), + /// independent of `selected`'s entity handles. Cleared whenever entity + /// selection changes so the two stay mutually exclusive; validated + /// against the current scope's constraint set at use, since switching + /// sketch scope can leave a stale id behind. + pub selected_constraint: Option, /// Session-only ISOLATEOBJECTS / HIDEOBJECTS state. Never written to DWG/DXF. pub object_isolation: ObjectIsolationState, /// Entity handles temporarily removed from the base render while an @@ -2171,6 +2178,7 @@ impl Scene { lighting_cache: RefCell::new(HashMap::default()), selected: HashSet::default(), selected_order: Vec::new(), + selected_constraint: None, object_isolation: ObjectIsolationState::default(), preview_hidden: HashSet::default(), command_preview_hidden: HashSet::default(), diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index e5aed1d5d..98c637415 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -62,8 +62,13 @@ pub enum PropValue { /// row for one persistent sketch constraint. Clicking it selects every /// entity in `handles` in the viewport. `conflicting` mirrors /// `SketchConstraintSet::conflicts`, tinting the row the same danger - /// color the constraint's glyph pill already uses. - EntityLink { handles: Vec, conflicting: bool }, + /// color the constraint's glyph pill already uses. `id` routes the + /// row's own delete button back to this specific constraint. + EntityLink { + id: crate::scene::sketch_constraints::ConstraintId, + handles: Vec, + conflicting: bool, + }, /// One row of the document-wide named-parameter table (Parameters /// section, shown when nothing is selected). `index` is the row's /// position in `ParameterTable::iter()` order — stable across edits to diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 46fdc260f..d30b0de2e 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -72,6 +72,7 @@ impl Scene { if handles.is_empty() { return; } + self.selected_constraint = None; let expanded = self.expanded_with_leaders(handles); let mut changed = false; for handle in expanded { @@ -111,6 +112,7 @@ impl Scene { expanded } pub fn select_entity(&mut self, handle: Handle, exclusive: bool) { + self.selected_constraint = None; let handles = self.handles_expanded_for_leader_annotations(&[handle]); let mut changed = false; @@ -133,6 +135,7 @@ impl Scene { } pub fn deselect_all(&mut self) { + self.selected_constraint = None; if self.selected.is_empty() { return; } @@ -215,6 +218,7 @@ impl Scene { /// only when its contents actually changed. History/file/command paths must /// use this instead of assigning `selected` directly. pub(crate) fn replace_selection(&mut self, selected: HashSet) { + self.selected_constraint = None; let handles: Vec = selected.iter().copied().collect(); let selected: HashSet = self .handles_expanded_for_leader_annotations(&handles) diff --git a/src/scene/sketch_constraints.rs b/src/scene/sketch_constraints.rs index 661c9ab9a..2907ccc1a 100644 --- a/src/scene/sketch_constraints.rs +++ b/src/scene/sketch_constraints.rs @@ -640,6 +640,98 @@ impl super::Scene { } } + /// Screen-projected sketch-constraint glyph placements for `scope`: + /// `(id, anchor, outward_screen_direction, label, is_conflicting)` for + /// every enabled, visible constraint whose glyph projects on-screen. + /// `vp_size` is the full canvas size (as `SelectionState::vp_size` + /// reports it), matching what `viewport_edit_frame`/ + /// `active_model_tile_bounds` expect. Mirrors the projection + /// `crate::app::view` builds its own render list with, and is reused by + /// [`constraint_glyph_hit`](Self::constraint_glyph_hit) — both feed the + /// same `(anchor, outward, label)` triples into + /// `crate::ui::overlay::constraint_glyph_box`/`constraint_glyph_offsets`, + /// so hit-testing can never drift from what's actually drawn. + pub fn constraint_glyph_placements_screen( + &self, + scope: SketchScope, + vp_size: (f32, f32), + show_values: bool, + ) -> Vec<(ConstraintId, iced::Point, [f32; 2], String, bool)> { + let Some(set) = self.sketch_constraint_set(scope) else { + return Vec::new(); + }; + if set.constraints.is_empty() { + return Vec::new(); + } + let edit_frame = self.viewport_edit_frame(vp_size); + let bounds = match &edit_frame { + Some((_, full)) => *full, + None => self.active_model_tile_bounds(vp_size.0, vp_size.1), + }; + let (view_rot, eye) = if let Some((cam, _)) = &edit_frame { + (cam.view_proj_rte(bounds), cam.eye()) + } else { + let cam = self.camera.borrow(); + (cam.view_proj_rte(bounds), cam.eye()) + }; + set.constraints + .iter() + .filter(|c| c.enabled) + .filter(|c| self.is_sketch_constraint_visible(scope, c.id)) + .filter_map(|c| { + let (anchor, outward) = glyph_placement(&self.document, c)?; + let screen = crate::scene::pick::grip::project_rte( + glam::DVec3::new(anchor.x, anchor.y, anchor.z), + view_rot, + eye, + bounds, + )?; + let outward_screen = crate::scene::pick::grip::project_rte( + glam::DVec3::new( + anchor.x + outward.x, + anchor.y + outward.y, + anchor.z + outward.z, + ), + view_rot, + eye, + bounds, + )?; + let direction = (outward_screen - screen).normalize_or(glam::Vec2::NEG_Y); + let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y); + let is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id); + let label = if show_values { + glyph_label(c) + } else { + c.kind.glyph_symbol().to_string() + }; + point.x.is_finite().then_some((c.id, point, direction.to_array(), label, is_conflicting)) + }) + .collect() + } + + /// Hit-tests screen point `p` (same coordinate space as `p_full` in the + /// viewport click handler) against the glyph pills from + /// [`constraint_glyph_placements_screen`](Self::constraint_glyph_placements_screen), + /// via `crate::ui::overlay::constraint_glyph_hit_test`'s shared layout + /// math, so a click only registers where the pill is actually drawn. + pub fn constraint_glyph_hit( + &self, + scope: SketchScope, + vp_size: (f32, f32), + show_values: bool, + p: iced::Point, + ) -> Option { + let placements = self.constraint_glyph_placements_screen(scope, vp_size, show_values); + let glyphs: Vec<(iced::Point, [f32; 2], String, bool)> = placements + .iter() + .map(|(_, point, direction, label, is_conflicting)| { + (*point, *direction, label.clone(), *is_conflicting) + }) + .collect(); + let index = crate::ui::overlay::constraint_glyph_hit_test(&glyphs, p)?; + Some(placements[index].0) + } + /// Handle remapping lives in each command that duplicates /// entities — `Scene::copy_entities`' `handle_map` (COPY/ARRAY/MIRROR, /// `src/scene/modify.rs`) and `OpenCADStudio::finalize_paste`'s own diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 4cd3418d2..e7b403fdc 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -94,6 +94,34 @@ fn constraint_glyph_offsets(glyphs: &[(Point, [f32; 2], String, bool)]) -> Vec Option { + let offsets = constraint_glyph_offsets(glyphs); + glyphs + .iter() + .zip(offsets) + .enumerate() + .rev() + .find_map(|(index, ((anchor, outward, label, _), tangent_offset))| { + let (top_left, size) = constraint_glyph_box(*anchor, *outward, label, tangent_offset); + let within = p.x >= top_left.x + && p.x <= top_left.x + size.width + && p.y >= top_left.y + && p.y <= top_left.y + size.height; + within.then_some(index) + }) +} + /// Convert CURSORSIZE to a screen-space arm length while keeping the original /// 60 px cursor at the default value and the full-viewport result at 100. pub(crate) fn crosshair_arm_px(bounds: iced::Rectangle, value: i32) -> f32 { @@ -723,7 +751,7 @@ pub fn selection_overlay<'a>( crosshair_bg: [f32; 4], crosshair: CrosshairOptions, selection_visual: SelectionVisualOptions, - constraint_glyphs: Vec<(Point, [f32; 2], String, bool)>, + constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool)>, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -806,8 +834,10 @@ struct SelectionCanvas { crosshair_bg: [f32; 4], crosshair: CrosshairOptions, selection_visual: SelectionVisualOptions, - /// Constraint glyph anchor, outward screen direction, label, and conflict state. - constraint_glyphs: Vec<(Point, [f32; 2], String, bool)>, + /// Constraint glyph anchor, outward screen direction, label, conflict + /// state, and whether the pill itself is the current click-to-select + /// target (`Scene::selected_constraint`) — for the current scope. + constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool)>, } fn draw_grip_marker( @@ -1703,9 +1733,21 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&b1, stroke.clone()); frame.stroke(&b2, stroke); } - // Constraint glyphs are visual-only and have no hit testing. + // Click-to-select hit testing against these pills lives in + // `Scene::constraint_glyph_hit`, sharing this same size formula so + // the clickable area never drifts from what's drawn here. if !self.constraint_glyphs.is_empty() { - let offsets = constraint_glyph_offsets(&self.constraint_glyphs); + // `constraint_glyph_offsets` only needs the anchor/outward/label/ + // conflict quadruple it was written against; project away the + // trailing `is_selected` flag rather than widen its signature. + let glyphs_for_offsets: Vec<(Point, [f32; 2], String, bool)> = self + .constraint_glyphs + .iter() + .map(|(anchor, outward, label, is_conflicting, _)| { + (*anchor, *outward, label.clone(), *is_conflicting) + }) + .collect(); + let offsets = constraint_glyph_offsets(&glyphs_for_offsets); let normal_bg = theme.palette().primary.base.color; let normal_fg = theme.palette().primary.base.text; // A redundant or conflicting constraint gets the danger palette @@ -1714,7 +1756,8 @@ impl canvas::Program for SelectionCanvas { // would show, surfaced right on the geometry. let conflict_bg = theme.palette().danger.base.color; let conflict_fg = theme.palette().danger.base.text; - for ((anchor, outward, label, is_conflicting), tangent_offset) in + let selected_ring = theme.palette().primary.strong.color; + for ((anchor, outward, label, is_conflicting, is_selected), tangent_offset) in self.constraint_glyphs.iter().zip(offsets) { if !anchor.x.is_finite() || !anchor.y.is_finite() { @@ -1729,6 +1772,12 @@ impl canvas::Program for SelectionCanvas { (size.height * 0.5).into(), ); frame.fill(&pill, bg); + if *is_selected { + frame.stroke( + &pill, + canvas::Stroke::default().with_color(selected_ring).with_width(2.0), + ); + } frame.fill_text(canvas::Text { content: label.clone(), position: Point::new( diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 507c150d6..ef04d6785 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -865,8 +865,8 @@ impl PropertiesPanel { self.render_hatch_pattern_row(label, current) } PropValue::AttrText { tag, value } => self.render_attr_row(tag, value), - PropValue::EntityLink { handles, conflicting } => { - render_entity_link_row(label, handles.clone(), *conflicting) + PropValue::EntityLink { id, handles, conflicting } => { + render_entity_link_row(label, *id, handles.clone(), *conflicting) } PropValue::ParamRow { index, name, formula, resolved } => { self.render_param_row(*index, name, formula, resolved) @@ -1933,8 +1933,15 @@ fn render_ro_with_tooltip_row<'a>( /// field, so this doesn't use the usual `prop_row_widget` label|value split. /// Clicking it selects every entity in `handles`; a conflicting/redundant /// constraint (mirrors the viewport glyph pill's own color cue) tints red. -fn render_entity_link_row<'a>(label: &'a str, handles: Vec, conflicting: bool) -> Element<'a, Message> { - let btn = button(text(label).size(FONT_SZ).width(Length::Fill)) +/// A trailing `✕` deletes the constraint outright (undoable), matching +/// `PropParamDelete`'s own row-delete button in the Parameters section. +fn render_entity_link_row<'a>( + label: &'a str, + id: crate::scene::sketch_constraints::ConstraintId, + handles: Vec, + conflicting: bool, +) -> Element<'a, Message> { + let link_btn = button(text(label).size(FONT_SZ).width(Length::Fill)) .on_press(Message::PropConstraintLinkClick(handles)) .style(move |theme: &Theme, status| { let palette = theme.palette(); @@ -1955,7 +1962,13 @@ fn render_entity_link_row<'a>(label: &'a str, handles: Vec, conflicting: }) .padding([3, 8]) .width(Length::Fill); - container(btn).width(Length::Fill).into() + let delete_btn = button(text("\u{2715}").size(FONT_SZ)) + .on_press(Message::PropConstraintDelete(id)) + .style(button::text) + .padding([2, 6]); + container(row![link_btn, delete_btn].spacing(2).align_y(iced::Center)) + .width(Length::Fill) + .into() } // ── Parameters section: "+ Add parameter" row ────────────────────────────── From 0f739b459d251c156558591060969173da8a38b3 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Tue, 15 Sep 2026 00:52:01 +0200 Subject: [PATCH 12/33] Add bounding-box object alignment (ALIGNLEFT/TOP/etc.), ported from Mac2CAM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six one-shot alignment commands for the current selection — ALIGNLEFT/ALIGNHCENTER/ALIGNRIGHT/ALIGNTOP/ALIGNVCENTER/ALIGNBOTTOM — distinct from the existing point-picking ALIGN/ALIGN3D command: each nudges every selected object (or complete group, via new Scene::selected_object_units) so its bounding-box edge/center lines up with the extreme edge/center already present in the selection, closer to a word processor's "align objects" than AutoCAD's 3-point ALIGN. Wired as plain one-click Tool entries in the Modify extension panel, next to the existing ALIGN entry, matching that panel's flat-list convention (no point prompts, so no dropdown submenu needed). Co-Authored-By: Claude Sonnet 5 --- assets/icons/align_bottom.svg | 4 + assets/icons/align_hcenter.svg | 4 + assets/icons/align_left.svg | 4 + assets/icons/align_right.svg | 4 + assets/icons/align_top.svg | 4 + assets/icons/align_vcenter.svg | 4 + src/app/commands/inquiry.rs | 193 +++++++++++++++++++++++++++++++ src/modules/draw/modify/align.rs | 8 ++ src/scene/group_layer.rs | 63 ++++++++++ src/ui/ribbon/modify_panel.rs | 41 +++++++ 10 files changed, 329 insertions(+) create mode 100644 assets/icons/align_bottom.svg create mode 100644 assets/icons/align_hcenter.svg create mode 100644 assets/icons/align_left.svg create mode 100644 assets/icons/align_right.svg create mode 100644 assets/icons/align_top.svg create mode 100644 assets/icons/align_vcenter.svg diff --git a/assets/icons/align_bottom.svg b/assets/icons/align_bottom.svg new file mode 100644 index 000000000..ca7fa2bea --- /dev/null +++ b/assets/icons/align_bottom.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/align_hcenter.svg b/assets/icons/align_hcenter.svg new file mode 100644 index 000000000..7ea838f62 --- /dev/null +++ b/assets/icons/align_hcenter.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/align_left.svg b/assets/icons/align_left.svg new file mode 100644 index 000000000..aedbf0ec9 --- /dev/null +++ b/assets/icons/align_left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/align_right.svg b/assets/icons/align_right.svg new file mode 100644 index 000000000..35f934aa0 --- /dev/null +++ b/assets/icons/align_right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/align_top.svg b/assets/icons/align_top.svg new file mode 100644 index 000000000..99efe0452 --- /dev/null +++ b/assets/icons/align_top.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/align_vcenter.svg b/assets/icons/align_vcenter.svg new file mode 100644 index 000000000..4740c61fc --- /dev/null +++ b/assets/icons/align_vcenter.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index aa9c088a7..f96c77268 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -1,6 +1,100 @@ use super::*; impl OpenCADStudio { + /// ALIGNLEFT/ALIGNHCENTER/ALIGNRIGHT/ALIGNTOP/ALIGNVCENTER/ALIGNBOTTOM: + /// nudges each selected object (or complete group — see + /// `Scene::selected_object_units`) so its bounding-box edge/center lines + /// up with the extreme edge/center of the whole selection. Distinct from + /// the point-picking `AlignCommand` (`ALIGN`/`ALIGN3D`): this is a + /// one-shot bounds alignment with no point prompts, closer to a + /// word processor's "align objects" toolbar than AutoCAD's 3-point ALIGN. + fn align_selected_bounds(&mut self, i: usize, command: &str) { + use crate::command::EntityTransform; + use glam::DVec3; + + let handles: Vec<_> = self.tabs[i] + .scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + if handles.len() < 2 { + self.command_line + .push_info(crate::t!("Select at least two unlocked objects to align.").as_ref()); + return; + } + + let bounds: Vec<_> = self.tabs[i] + .scene + .selected_object_units(&handles) + .into_iter() + .filter_map(|unit| { + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + for handle in &unit { + let entity = self.tabs[i].scene.document.get_entity(*handle)?; + let bb = entity.as_entity().bounding_box(); + min_x = min_x.min(bb.min.x); + min_y = min_y.min(bb.min.y); + max_x = max_x.max(bb.max.x); + max_y = max_y.max(bb.max.y); + } + [min_x, min_y, max_x, max_y] + .iter() + .all(|value| value.is_finite()) + .then_some((unit, min_x, min_y, max_x, max_y)) + }) + .collect(); + if bounds.len() < 2 { + self.command_line + .push_info(crate::t!("The selected objects do not provide usable bounds.").as_ref()); + return; + } + + let min_x = bounds.iter().map(|(_, x, _, _, _)| *x).fold(f64::INFINITY, f64::min); + let min_y = bounds.iter().map(|(_, _, y, _, _)| *y).fold(f64::INFINITY, f64::min); + let max_x = bounds.iter().map(|(_, _, _, x, _)| *x).fold(f64::NEG_INFINITY, f64::max); + let max_y = bounds.iter().map(|(_, _, _, _, y)| *y).fold(f64::NEG_INFINITY, f64::max); + let center_x = (min_x + max_x) * 0.5; + let center_y = (min_y + max_y) * 0.5; + + let label = match command { + "ALIGNLEFT" => "Align Left", + "ALIGNHCENTER" => "Align Horizontal Centers", + "ALIGNRIGHT" => "Align Right", + "ALIGNTOP" => "Align Top", + "ALIGNVCENTER" => "Align Vertical Centers", + "ALIGNBOTTOM" => "Align Bottom", + _ => command, + }; + let pending = self.begin_undo(i, label, handles.len(), true); + for (unit, unit_min_x, unit_min_y, unit_max_x, unit_max_y) in &bounds { + let (dx, dy) = match command { + "ALIGNLEFT" => (min_x - unit_min_x, 0.0), + "ALIGNHCENTER" => (center_x - (unit_min_x + unit_max_x) * 0.5, 0.0), + "ALIGNRIGHT" => (max_x - unit_max_x, 0.0), + "ALIGNTOP" => (0.0, max_y - unit_max_y), + "ALIGNVCENTER" => (0.0, center_y - (unit_min_y + unit_max_y) * 0.5), + "ALIGNBOTTOM" => (0.0, min_y - unit_min_y), + _ => (0.0, 0.0), + }; + self.tabs[i].scene.transform_entities( + unit, + &EntityTransform::Translate(DVec3::new(dx, dy, 0.0)), + ); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + self.command_line.push_output( + crate::tf!("{command}: aligned {} object(s).", bounds.len()).as_ref(), + ); + } + pub(super) fn dispatch_inquiry(&mut self, cmd: &str, i: usize) -> Option> { match cmd { "3DORBIT" => { @@ -980,6 +1074,11 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd)); } + "ALIGNLEFT" | "ALIGNHCENTER" | "ALIGNRIGHT" | "ALIGNTOP" | "ALIGNVCENTER" + | "ALIGNBOTTOM" => { + self.align_selected_bounds(i, cmd); + } + "LENGTHEN" => { use crate::modules::draw::modify::lengthen::LengthenCommand; let cmd = LengthenCommand::new(); @@ -2211,3 +2310,97 @@ mod flatten_tests { ); } } + +#[cfg(test)] +mod align_selected_bounds_tests { + use super::*; + use acadrust::entities::{EntityType, Line}; + use acadrust::types::Vector3; + + fn add_line(app: &mut OpenCADStudio, x1: f64, y1: f64, x2: f64, y2: f64) -> acadrust::Handle { + app.tabs[app.active_tab] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::new(x1, y1, 0.0), + Vector3::new(x2, y2, 0.0), + ))) + } + + fn line_start_x(app: &OpenCADStudio, handle: acadrust::Handle) -> f64 { + match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(EntityType::Line(l)) => l.start.x.min(l.end.x), + other => panic!("expected a Line, got {other:?}"), + } + } + + fn line_max_y(app: &OpenCADStudio, handle: acadrust::Handle) -> f64 { + match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(EntityType::Line(l)) => l.start.y.max(l.end.y), + other => panic!("expected a Line, got {other:?}"), + } + } + + /// A single selected object has no "other object" to align to — refuse + /// with an informational message rather than silently moving nothing + /// (or panicking on the empty-bounds case). + #[test] + fn refuses_with_fewer_than_two_selected_objects() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let a = add_line(&mut app, 0.0, 0.0, 2.0, 1.0); + app.tabs[app.active_tab].scene.select_entities(&[a]); + let i = app.active_tab; + + app.dispatch_inquiry("ALIGNLEFT", i); + assert_eq!(line_start_x(&app, a), 0.0, "the lone object must not move"); + } + + /// ALIGNLEFT moves every selected object's left edge to the leftmost + /// edge already present in the selection; ALIGNTOP does the same for + /// the topmost edge. Both are undoable as one step. + #[test] + fn aligns_left_and_top_edges_and_is_undoable() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let left = add_line(&mut app, 0.0, 0.0, 1.0, 1.0); // leftmost & topmost already + let right = add_line(&mut app, 5.0, -3.0, 8.0, -2.0); // further right and lower + app.tabs[app.active_tab].scene.select_entities(&[left, right]); + let i = app.active_tab; + + app.dispatch_inquiry("ALIGNLEFT", i); + assert_eq!(line_start_x(&app, left), 0.0, "the already-leftmost object must not move"); + assert_eq!(line_start_x(&app, right), 0.0, "the other object's left edge should meet it"); + + app.dispatch_inquiry("ALIGNTOP", i); + assert_eq!(line_max_y(&app, left), 1.0, "the already-topmost object must not move"); + assert_eq!(line_max_y(&app, right), 1.0, "the other object's top edge should meet it"); + + app.undo_steps(1); // undo ALIGNTOP + assert_eq!(line_max_y(&app, right), -2.0, "undo should restore the pre-ALIGNTOP position"); + app.undo_steps(1); // undo ALIGNLEFT + assert_eq!(line_start_x(&app, right), 5.0, "undo should restore the original geometry"); + } + + /// A complete, fully-selected group (`Scene::selected_object_units`) + /// moves as one rigid unit instead of each member line sliding to meet + /// the *other* members of its own group. + #[test] + fn a_complete_group_aligns_as_one_rigid_unit() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let a = add_line(&mut app, 5.0, 0.0, 6.0, 1.0); + let b = add_line(&mut app, 7.0, 0.0, 8.0, 1.0); + let other = add_line(&mut app, 0.0, 0.0, 1.0, 1.0); + app.tabs[app.active_tab].scene.create_group("pair".to_string(), vec![a, b]); + app.tabs[app.active_tab].scene.select_entities(&[a, b, other]); + let i = app.active_tab; + + app.dispatch_inquiry("ALIGNLEFT", i); + // The group's own left edge (a's, at x=5) moves to meet `other`'s + // left edge (x=0): a shifts by -5, and b — sharing the same + // transform — must shift by exactly the same amount, not collapse + // onto `a`. + assert_eq!(line_start_x(&app, a), 0.0); + assert_eq!(line_start_x(&app, b), 2.0, "b must keep its offset from a, not collapse onto it"); + } +} diff --git a/src/modules/draw/modify/align.rs b/src/modules/draw/modify/align.rs index 118dc8a87..c28454aa8 100644 --- a/src/modules/draw/modify/align.rs +++ b/src/modules/draw/modify/align.rs @@ -304,6 +304,14 @@ impl AlignCommand { } inventory::submit!(crate::command::CommandRegistration { names: &["ALIGN"] }); // AlignCommand +// ALIGNLEFT/ALIGNHCENTER/ALIGNRIGHT/ALIGNTOP/ALIGNVCENTER/ALIGNBOTTOM: one-shot +// bounding-box alignment, dispatched directly (no CadCommand of their own) by +// `OpenCADStudio::align_selected_bounds` in `src/app/commands/inquiry.rs`. +inventory::submit!(crate::command::CommandRegistration { + names: &[ + "ALIGNLEFT", "ALIGNHCENTER", "ALIGNRIGHT", "ALIGNTOP", "ALIGNVCENTER", "ALIGNBOTTOM", + ], +}); #[cfg(test)] mod tests { diff --git a/src/scene/group_layer.rs b/src/scene/group_layer.rs index e636bde8f..7c88d7f65 100644 --- a/src/scene/group_layer.rs +++ b/src/scene/group_layer.rs @@ -203,6 +203,42 @@ impl Scene { self.bump_selection_set(); } } + + /// Partition selected entity handles into visual objects for operations + /// such as edge alignment. A selectable CAD group is one object and must + /// receive one shared transform; treating its member lines independently + /// can collapse a rectangle or triangle onto its own edge. + pub fn selected_object_units(&self, handles: &[Handle]) -> Vec> { + let selected: HashSet = handles.iter().copied().collect(); + let mut claimed = HashSet::default(); + let mut units = Vec::new(); + for group in self.groups().filter(|group| group.selectable) { + let members: Vec<_> = group + .entities + .iter() + .copied() + .filter(|handle| selected.contains(handle)) + .collect(); + if !members.is_empty() + && members.iter().all(|handle| !claimed.contains(handle)) + && group + .entities + .iter() + .all(|handle| selected.contains(handle)) + { + claimed.extend(members.iter().copied()); + units.push(members); + } + } + units.extend( + handles + .iter() + .copied() + .filter(|handle| !claimed.contains(handle)) + .map(|handle| vec![handle]), + ); + units + } } #[cfg(test)] @@ -247,4 +283,31 @@ mod group_expansion_tests { assert_eq!(both, [a, b, c, d, e].into_iter().collect::>()); assert!(!both.contains(&lonely)); } + + /// `selected_object_units` is `Scene::selected_object_units`'s + /// counterpart for operations like edge alignment (`ALIGNTOP`, etc.): + /// a complete, fully-selected group collapses to one unit sharing a + /// single transform, so aligning a triangle's edge to another object + /// moves the whole triangle rather than collapsing its own lines onto + /// each other. + #[test] + fn selected_object_units_keep_a_complete_group_together() { + use acadrust::entities::{EntityType, Line}; + use acadrust::types::Vector3; + + let mut scene = Scene::new(); + let mut line = |x: f64| { + scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(x, 0.0, 0.0), + Vector3::new(x + 1.0, 0.0, 0.0), + ))) + }; + let (a, b, c, standalone) = (line(0.0), line(1.0), line(2.0), line(10.0)); + scene.create_group("triangle".into(), vec![a, b, c]); + + let units = scene.selected_object_units(&[a, b, c, standalone]); + assert_eq!(units.len(), 2); + assert!(units.iter().any(|unit| unit == &vec![a, b, c])); + assert!(units.iter().any(|unit| unit == &vec![standalone])); + } } diff --git a/src/ui/ribbon/modify_panel.rs b/src/ui/ribbon/modify_panel.rs index 835e03c27..e7256acce 100644 --- a/src/ui/ribbon/modify_panel.rs +++ b/src/ui/ribbon/modify_panel.rs @@ -37,6 +37,47 @@ pub(super) const TOOLS: &[Tool] = &[ icon: include_bytes!("../../../assets/icons/modify_align.svg"), options: &[], }, + // ALIGNLEFT/ALIGNHCENTER/ALIGNRIGHT/ALIGNTOP/ALIGNVCENTER/ALIGNBOTTOM: + // one-shot bounding-box alignment of the current selection — distinct + // from "ALIGN" above (point-picked 3D placement). No point prompts, so + // each is a plain one-click Tool rather than a dropdown submenu, matching + // this panel's existing flat-list convention. + Tool { + command: "ALIGNLEFT", + label: "Align Left", + icon: include_bytes!("../../../assets/icons/align_left.svg"), + options: &[], + }, + Tool { + command: "ALIGNHCENTER", + label: "Align Horizontal Centers", + icon: include_bytes!("../../../assets/icons/align_hcenter.svg"), + options: &[], + }, + Tool { + command: "ALIGNRIGHT", + label: "Align Right", + icon: include_bytes!("../../../assets/icons/align_right.svg"), + options: &[], + }, + Tool { + command: "ALIGNTOP", + label: "Align Top", + icon: include_bytes!("../../../assets/icons/align_top.svg"), + options: &[], + }, + Tool { + command: "ALIGNVCENTER", + label: "Align Vertical Centers", + icon: include_bytes!("../../../assets/icons/align_vcenter.svg"), + options: &[], + }, + Tool { + command: "ALIGNBOTTOM", + label: "Align Bottom", + icon: include_bytes!("../../../assets/icons/align_bottom.svg"), + options: &[], + }, Tool { command: "BREAK", label: "Break", From ed2e0fbc4ad704a94a915afe0b6457acf6ee36e0 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Tue, 15 Sep 2026 01:26:48 +0200 Subject: [PATCH 13/33] Wire up the Properties panel's Parameters section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PropValue::ParamRow/ParamAddRow, the PropParamInput/Commit/Delete/AddNew messages, their command_driver handlers (on_prop_param_commit/_delete/ _add_new, resolve_named_parameter_edit), and their render functions all already existed — but nothing ever constructed the "Parameters" section that would route data to them, so the whole inline add/rename/redefine/ delete workflow was unreachable from the UI. Adds that missing section to the no-selection page in refresh_properties, alongside a new PropValue::ParamsVisibilityToggle header row (mirrors the existing show_constraint_values setting) for toggling whether constraint pills show their driven value/parameter name. Users can now manage named parameters inline in the Properties panel (live resolved values, add/rename/redefine/delete) as an alternative to the separate PARAMETERS modal, which continues to work unchanged. Co-Authored-By: Claude Sonnet 5 --- src/app/command_driver.rs | 68 +++++++++++++++++++++++++++++++++++++++ src/app/properties.rs | 39 ++++++++++++++++++++++ src/scene/model/object.rs | 5 +++ src/scene/selection.rs | 6 ++-- src/ui/properties.rs | 38 ++++++++++++++++++++++ 5 files changed, 154 insertions(+), 2 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index af720ea76..f4f19d6d1 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -7676,6 +7676,74 @@ mod sketch_constraint_undo_tests { } } +/// The `PropParamInput`/`PropParamCommit`/`PropParamDelete`/`PropParamAddNew` +/// handlers, `PropValue::ParamRow`/`ParamAddRow`/`ParamsVisibilityToggle`, +/// and their render functions already existed — nothing built the +/// Properties panel's "Parameters" section that would ever construct or +/// route to them, so the whole inline add/rename/redefine/delete workflow +/// was unreachable. These tests exercise it end to end now that the section +/// is wired up in `refresh_properties`. +#[cfg(test)] +mod properties_panel_parameters_tests { + use super::*; + + /// Finds a `PropValue::ParamRow`'s name across every rendered Properties + /// panel section — the panel is a rebuilt-on-demand snapshot + /// (`refresh_properties`), not a live view, so this only sees whatever + /// it was last rebuilt with. + fn param_row_names(app: &OpenCADStudio) -> Vec { + app.tabs[app.active_tab] + .properties + .sections + .iter() + .flat_map(|s| &s.props) + .filter_map(|p| match &p.value { + crate::scene::model::object::PropValue::ParamRow { name, .. } => Some(name.clone()), + _ => None, + }) + .collect() + } + + #[test] + fn no_selection_page_shows_every_named_parameter() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let _ = app.tabs[app.active_tab].scene.named_parameters_mut().set("hole_dia", "12"); + app.refresh_properties(); + assert_eq!(param_row_names(&app), vec!["hole_dia".to_string()]); + } + + #[test] + fn add_rename_redefine_and_delete_a_parameter_through_the_panel() { + use crate::ui::window::named_parameters::ParamField; + + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + + // Add: the first row is auto-named "param1", and the panel reflects + // it immediately (on_prop_param_add_new refreshes internally). + let _ = app.update(Message::PropParamAddNew); + assert_eq!(param_row_names(&app), vec!["param1".to_string()]); + let index = app.tabs[app.active_tab].scene.named_parameters().iter().position(|p| p.name == "param1").unwrap(); + + // Rename param1 -> hole_dia (commit-on-submit, not per keystroke). + let _ = app.update(Message::PropParamInput { index, field: ParamField::Name, value: "hole_dia".to_string() }); + assert_eq!(param_row_names(&app), vec!["param1".to_string()], "typing alone must not commit"); + let _ = app.update(Message::PropParamCommit { index, field: ParamField::Name }); + assert_eq!(param_row_names(&app), vec!["hole_dia".to_string()]); + + // Redefine its formula. + let _ = app.update(Message::PropParamInput { index, field: ParamField::Formula, value: "8".to_string() }); + let _ = app.update(Message::PropParamCommit { index, field: ParamField::Formula }); + assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("hole_dia"), Ok(8.0)); + + // Delete: gone from both the table and the panel. + let _ = app.update(Message::PropParamDelete(index)); + assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("hole_dia")); + assert!(param_row_names(&app).is_empty()); + } +} + #[cfg(all(test, not(target_arch = "wasm32")))] mod delobj_tests { use super::*; diff --git a/src/app/properties.rs b/src/app/properties.rs index 7096e7b03..fd7b6b3c3 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -471,6 +471,42 @@ impl OpenCADStudio { }, ], }, + // Document-wide named-parameter table embedded here + // instead of only the separate PARAMETERS modal — + // see `PropValue::ParamRow`'s doc comment. Belongs + // on the no-selection (drawing-level) page, not a + // per-entity one: a parameter isn't owned by any + // one entity. + PropSection { + title: t!("Parameters").into_owned(), + props: { + let mut props: Vec = vec![Property { + label: String::new(), + field: "show_named_parameters", + value: PropValue::ParamsVisibilityToggle(self.show_constraint_values), + }]; + props.extend(scene + .named_parameters() + .iter() + .enumerate() + .map(|(index, p)| Property { + label: String::new(), + field: "named_parameter", + value: PropValue::ParamRow { + index, + name: p.name.clone(), + formula: p.source.clone(), + resolved: scene.named_parameters().resolve(&p.name).map_err(|e| e.to_string()), + }, + })); + props.push(Property { + label: String::new(), + field: "named_parameter_add", + value: PropValue::ParamAddRow, + }); + props + }, + }, ]; ui::PropertiesPanel { title: t!("No selection").into_owned(), @@ -3231,6 +3267,9 @@ fn make_sections_read_only( PropValue::EntityLink { handles, .. } => format!("{} entity link(s)", handles.len()), PropValue::ParamRow { name, formula, .. } => format!("{name} = {formula}"), PropValue::ParamAddRow => String::new(), + PropValue::ParamsVisibilityToggle(value) => { + if *value { t!("Shown") } else { t!("Hidden") }.into_owned() + } }; property.field = "locked_read_only"; property.value = PropValue::ReadOnly(text); diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index e5aed1d5d..c05f08b83 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -80,6 +80,11 @@ pub enum PropValue { }, /// The trailing "+ Add parameter" row in the Parameters section. ParamAddRow, + /// The Parameters section's leading header row (no-selection page only): + /// a global on/off toggle for whether any constraint pill in the + /// viewport shows its driven value/parameter-name text. Mirrors + /// `App::show_constraint_values`. + ParamsVisibilityToggle(bool), } /// A single property row in the Properties panel. diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 46fdc260f..b4f477023 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -750,7 +750,8 @@ impl Scene { | PropValue::FieldLwVaries { .. } | PropValue::EntityLink { .. } | PropValue::ParamRow { .. } - | PropValue::ParamAddRow => continue, + | PropValue::ParamAddRow + | PropValue::ParamsVisibilityToggle(_) => continue, } }; out.push(choice(prop.field, prop.label, editor)); @@ -887,7 +888,8 @@ impl Scene { | PropValue::FieldLwVaries { .. } | PropValue::EntityLink { .. } | PropValue::ParamRow { .. } - | PropValue::ParamAddRow => return None, + | PropValue::ParamAddRow + | PropValue::ParamsVisibilityToggle(_) => return None, }) } } diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 507c150d6..c0d319d5e 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -872,6 +872,7 @@ impl PropertiesPanel { self.render_param_row(*index, name, formula, resolved) } PropValue::ParamAddRow => render_param_add_row(), + PropValue::ParamsVisibilityToggle(value) => render_params_visibility_toggle_row(*value), } } @@ -1973,6 +1974,43 @@ fn render_param_add_row<'a>() -> Element<'a, Message> { container(btn).width(Length::Fill).into() } +// ── Parameters section: leading global visibility toggle ─────────────────── + +/// The Parameters section's leading header row (no-selection page): a +/// global on/off toggle for whether any constraint pill in the viewport +/// shows its driven value/parameter-name text — lives next to the +/// named-parameter table it governs. +fn render_params_visibility_toggle_row<'a>(value: bool) -> Element<'a, Message> { + let btn_label = if value { t!("Shown").into_owned() } else { t!("Hidden").into_owned() }; + let btn = button( + row![ + crate::ui::icons::semantic(crate::ui::icons::layer_visible(value), 13.0), + text(btn_label).size(FONT_SZ), + ] + .spacing(6) + .align_y(iced::Center), + ) + .on_press(Message::ShowConstraintValuesChanged(!value)) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let pair = match status { + button::Status::Hovered | button::Status::Pressed => palette.background.weak, + _ => palette.background.base, + }; + button::Style { + background: Some(Background::Color(pair.color)), + border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into() }, + text_color: pair.text, + ..Default::default() + } + }) + .padding([4, 8]) + .width(Length::Fill); + container(row![text(t!("Show values").into_owned()).size(FONT_SZ).width(Length::Fill), btn].align_y(iced::Center)) + .width(Length::Fill) + .into() +} + /// Build a label | widget property row. fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<'a, Message> { prop_row_with_active(label, widget, false) From d738ebfcc65aff74a9f88426f6c9e5cb06fec9ba Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 15 Sep 2026 03:19:52 +0300 Subject: [PATCH 14/33] Standardize parametric constraints and interoperability --- Cargo.lock | 6 +- Cargo.toml | 6 +- benches/performance_benchmarks.rs | 173 +- crates/ocs_plugin_api/Cargo.toml | 4 +- crates/ocs_web_worker/Cargo.toml | 2 +- crates/plugin-template-api2/Cargo.toml | 2 +- docs/plugin-template-v2/Cargo.toml | 2 +- src/app/automation.rs | 85 +- src/app/command_driver.rs | 2130 +++++++++------ src/app/commands/draw.rs | 481 ++-- src/app/document.rs | 124 +- src/app/history.rs | 129 +- src/app/mod.rs | 19 +- src/app/properties.rs | 667 ++--- src/app/settings.rs | 5 - src/app/update/file.rs | 2011 +++++++------- src/app/update/mod.rs | 1022 +++---- src/app/update/viewport.rs | 830 +++--- src/app/view/mod.rs | 2355 ++++++++--------- src/app/view/modal.rs | 1 - src/command.rs | 180 +- src/modules/parametric/coincident.rs | 2 +- src/modules/parametric/point_on_entity.rs | 4 +- src/modules/parametric/tools.rs | 14 +- src/modules/parametric/value.rs | 271 +- src/scene/dimension_assoc.rs | 256 +- src/scene/dwg_native_constraints.rs | 2342 ++++++++++++---- src/scene/entity.rs | 429 ++- src/scene/mod.rs | 1248 ++++----- src/scene/model/object.rs | 19 +- src/scene/modify.rs | 158 +- src/scene/named_parameters.rs | 27 +- src/scene/named_parameters_persist.rs | 210 -- ...nstraints.rs => parametric_constraints.rs} | 609 +++-- .../{sketch_solve.rs => parametric_solve.rs} | 955 ++++++- src/scene/sketch_persist.rs | 393 --- src/ui/statusbar/mod.rs | 191 +- src/ui/window/named_parameters.rs | 10 +- src/ui/window/options.rs | 17 - ...lve.rs => parametric_constraints_solve.rs} | 337 +-- tests/sketch_constraints_xrecord_roundtrip.rs | 159 -- 41 files changed, 10038 insertions(+), 7847 deletions(-) delete mode 100644 src/scene/named_parameters_persist.rs rename src/scene/{sketch_constraints.rs => parametric_constraints.rs} (63%) rename src/scene/{sketch_solve.rs => parametric_solve.rs} (69%) delete mode 100644 src/scene/sketch_persist.rs rename tests/{sketch_constraints_solve.rs => parametric_constraints_solve.rs} (85%) delete mode 100644 tests/sketch_constraints_xrecord_roundtrip.rs diff --git a/Cargo.lock b/Cargo.lock index 5d95a48f7..7efbae01f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,7 +83,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=7709884f301cfe292f58a002a514939932be67d0#7709884f301cfe292f58a002a514939932be67d0" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=568a12c#568a12cb97c24e14b1cc488085b3f81cf89b94d9" dependencies = [ "ahash 0.8.12", "anyhow", @@ -919,7 +919,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b#2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b8b664a#b8b664ae993e392fb84689966a44fa9a3f4e38cd" dependencies = [ "acadrust", "cavalier_contours", @@ -930,7 +930,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b#2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b8b664a#b8b664ae993e392fb84689966a44fa9a3f4e38cd" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 4a83d5bcd..688a7c795 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,9 +32,9 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b" } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b8b664a", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b8b664a" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/benches/performance_benchmarks.rs b/benches/performance_benchmarks.rs index edde1bb36..4ae2dcf9c 100644 --- a/benches/performance_benchmarks.rs +++ b/benches/performance_benchmarks.rs @@ -48,16 +48,20 @@ use acadrust::{CadDocument, DxfReader, DxfWriter, EntityType}; use glam::{DVec3, Mat4, Vec3}; use iced::{Color, Point, Rectangle}; +use OpenCADStudio::scene::parametric_constraints::{ + ConstraintKind, ParametricRef, ParametricScope, +}; use OpenCADStudio::scene::pick::hit_test::{box_hit, click_hit}; use OpenCADStudio::scene::pick::interaction_index::InteractionIndex; use OpenCADStudio::scene::pick::selection_state::SelectionState; use OpenCADStudio::scene::pipeline::wire_arena::partition_wires; -use OpenCADStudio::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; use OpenCADStudio::scene::view::camera::Camera; use OpenCADStudio::scene::{ChangeKind, Scene}; use OpenCADStudio::snap::Snapper; use OpenCADStudio::ui::icons::{self, CHECK}; -use OpenCADStudio::ui::overlay::{grid_segments, GridCanvasState, GridKey, GridParams, GridStyle, should_reuse}; +use OpenCADStudio::ui::overlay::{ + grid_segments, should_reuse, GridCanvasState, GridKey, GridParams, GridStyle, +}; use OpenCADStudio::ui::properties::LinetypeItem; use OpenCADStudio::ui::ribbon::{LayerInfo, Ribbon}; @@ -109,8 +113,8 @@ impl Default for BenchmarkRunner { impl BenchmarkRunner { pub fn new() -> Self { let args: Vec = env::args().collect(); - let quick_mode = args.iter().any(|a| a == "--quick" || a == "-q") - || env::var("CAD_BENCH_QUICK").is_ok(); + let quick_mode = + args.iter().any(|a| a == "--quick" || a == "-q") || env::var("CAD_BENCH_QUICK").is_ok(); let filter = args .windows(2) @@ -220,22 +224,37 @@ impl BenchmarkRunner { /// Print summary table to stdout and persist JSON report. pub fn finish(&self) { - let baseline_map: HashMap = if let Some(ref path) = self.baseline_path { - if let Ok(content) = std::fs::read_to_string(path) { - if let Ok(report) = serde_json::from_str::(&content) { - println!("\nLoaded baseline from '{}' ({} metrics)", path.display(), report.metrics.len()); - report.metrics.into_iter().map(|m| (m.name.clone(), m)).collect() + let baseline_map: HashMap = + if let Some(ref path) = self.baseline_path { + if let Ok(content) = std::fs::read_to_string(path) { + if let Ok(report) = serde_json::from_str::(&content) { + println!( + "\nLoaded baseline from '{}' ({} metrics)", + path.display(), + report.metrics.len() + ); + report + .metrics + .into_iter() + .map(|m| (m.name.clone(), m)) + .collect() + } else { + eprintln!( + "Warning: Failed to parse baseline JSON at '{}'", + path.display() + ); + HashMap::new() + } } else { - eprintln!("Warning: Failed to parse baseline JSON at '{}'", path.display()); + eprintln!( + "Warning: Could not read baseline file at '{}'", + path.display() + ); HashMap::new() } } else { - eprintln!("Warning: Could not read baseline file at '{}'", path.display()); HashMap::new() - } - } else { - HashMap::new() - }; + }; println!("\n========================================================================================================================"); println!(" OPENCADSTUDIO PERFORMANCE BENCHMARK REPORT "); @@ -301,7 +320,11 @@ impl BenchmarkRunner { timestamp: chrono_now(), os: env::consts::OS.to_string(), arch: env::consts::ARCH.to_string(), - profile: if cfg!(debug_assertions) { "debug".to_string() } else { "release".to_string() }, + profile: if cfg!(debug_assertions) { + "debug".to_string() + } else { + "release".to_string() + }, is_quick_mode: self.quick_mode, metrics: self.results.clone(), }; @@ -311,9 +334,16 @@ impl BenchmarkRunner { let _ = std::fs::create_dir_all(parent); } if let Err(e) = std::fs::write(&self.output_path, json) { - eprintln!("Error saving metrics to '{}': {}", self.output_path.display(), e); + eprintln!( + "Error saving metrics to '{}': {}", + self.output_path.display(), + e + ); } else { - println!("Saved complete performance metrics JSON to: {}\n", self.output_path.display()); + println!( + "Saved complete performance metrics JSON to: {}\n", + self.output_path.display() + ); } } } @@ -381,9 +411,27 @@ fn bench_scene_entity_ingestion(runner: &mut BenchmarkRunner) { let y = (i / 50) as f64 * 50.0 + 6000.0; let mut pl = LwPolyline::new(); pl.vertices = vec![ - LwVertex { location: Vector2::new(x, y), bulge: 0.0, start_width: 0.0, end_width: 0.0, vertex_id: 0 }, - LwVertex { location: Vector2::new(x + 20.0, y), bulge: 0.5, start_width: 0.0, end_width: 0.0, vertex_id: 1 }, - LwVertex { location: Vector2::new(x + 20.0, y + 20.0), bulge: 0.0, start_width: 0.0, end_width: 0.0, vertex_id: 2 }, + LwVertex { + location: Vector2::new(x, y), + bulge: 0.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }, + LwVertex { + location: Vector2::new(x + 20.0, y), + bulge: 0.5, + start_width: 0.0, + end_width: 0.0, + vertex_id: 1, + }, + LwVertex { + location: Vector2::new(x + 20.0, y + 20.0), + bulge: 0.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 2, + }, ]; scene.add_entity(EntityType::LwPolyline(pl)); } @@ -462,8 +510,20 @@ fn bench_analytical_tessellation_zoom(runner: &mut BenchmarkRunner) { let mut pline = LwPolyline::new(); pline.is_closed = true; pline.vertices = vec![ - LwVertex { location: Vector2::new(x, y), bulge: 1.0, start_width: 0.0, end_width: 0.0, vertex_id: 0 }, - LwVertex { location: Vector2::new(x + 30.0, y), bulge: 1.0, start_width: 0.0, end_width: 0.0, vertex_id: 1 }, + LwVertex { + location: Vector2::new(x, y), + bulge: 1.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }, + LwVertex { + location: Vector2::new(x + 30.0, y), + bulge: 1.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 1, + }, ]; scene.add_entity(EntityType::LwPolyline(pline)); } @@ -610,7 +670,12 @@ fn bench_hit_test_picking(runner: &mut BenchmarkRunner) { let view_rot = Mat4::IDENTITY; let eye = DVec3::new(1000.0, 1000.0, 2000.0); - let bounds = Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 1080.0 }; + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; let n_queries = if runner.quick_mode { 200 } else { 1_000 }; let mut click_samples = Vec::with_capacity(5); @@ -620,7 +685,15 @@ fn bench_hit_test_picking(runner: &mut BenchmarkRunner) { for q in 0..n_queries { let cx = ((q * 37) % 1920) as f32; let cy = ((q * 53) % 1080) as f32; - let hit = click_hit(Point::new(cx, cy), wires.as_slice(), view_rot, eye, bounds, false, 8.0); + let hit = click_hit( + Point::new(cx, cy), + wires.as_slice(), + view_rot, + eye, + bounds, + false, + 8.0, + ); black_box(hit); } let per_query_us = (t0.elapsed().as_secs_f64() * 1_000_000.0) / (n_queries as f64); @@ -716,7 +789,12 @@ fn bench_osnap_evaluation(runner: &mut BenchmarkRunner) { let view_rot = Mat4::IDENTITY; let eye = DVec3::new(500.0, 500.0, 1000.0); - let bounds = Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 1080.0 }; + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; let grid_origin = Vec3::ZERO; let grid_axes = (Vec3::X, Vec3::Y, Vec3::Z); @@ -729,10 +807,7 @@ fn bench_osnap_evaluation(runner: &mut BenchmarkRunner) { let wx = (q as f64 * 7.3) % 2500.0; let wy = (q as f64 * 11.7) % 2500.0; let cursor_world = DVec3::new(wx, wy, 0.0); - let cursor_screen = Point::new( - ((q * 23) % 1920) as f32, - ((q * 31) % 1080) as f32, - ); + let cursor_screen = Point::new(((q * 23) % 1920) as f32, ((q * 31) % 1080) as f32); let hit = snapper.snap( cursor_world, @@ -798,11 +873,23 @@ fn bench_geometric_constraint_solving(runner: &mut BenchmarkRunner) { lines.push((h1, h2, h3)); } - let cs = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let cs = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); for &(h1, h2, h3) in &lines { - cs.add(ConstraintKind::Perpendicular, vec![SketchRef::whole(h1), SketchRef::whole(h2)], None); - cs.add(ConstraintKind::Perpendicular, vec![SketchRef::whole(h2), SketchRef::whole(h3)], None); - cs.add(ConstraintKind::Parallel, vec![SketchRef::whole(h1), SketchRef::whole(h3)], None); + cs.add( + ConstraintKind::Perpendicular, + vec![ParametricRef::whole(h1), ParametricRef::whole(h2)], + None, + ); + cs.add( + ConstraintKind::Perpendicular, + vec![ParametricRef::whole(h2), ParametricRef::whole(h3)], + None, + ); + cs.add( + ConstraintKind::Parallel, + vec![ParametricRef::whole(h1), ParametricRef::whole(h3)], + None, + ); } let target = lines[run_idx % lines.len()].0; @@ -858,7 +945,9 @@ fn bench_dxf_io_throughput(runner: &mut BenchmarkRunner) { for _ in 0..runs { let t0 = Instant::now(); - let bytes = DxfWriter::new(&doc).write_to_vec().expect("DXF write succeeds"); + let bytes = DxfWriter::new(&doc) + .write_to_vec() + .expect("DXF write succeeds"); write_samples.push(t0.elapsed().as_secs_f64() * 1000.0); dxf_bytes = bytes; } @@ -1095,8 +1184,12 @@ fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) { // ── 1. Uncached grid geometry generation ── if runner.should_run("ui_grid_geometry_uncached") { for _ in 0..10 { - let _ = black_box(grid_segments(view_rot1, eye1, bounds1, step1, origin1, axes1, limits1)); - let _ = black_box(grid_segments(view_rot2, eye2, bounds2, step2, origin2, axes2, limits2)); + let _ = black_box(grid_segments( + view_rot1, eye1, bounds1, step1, origin1, axes1, limits1, + )); + let _ = black_box(grid_segments( + view_rot2, eye2, bounds2, step2, origin2, axes2, limits2, + )); } let n = if runner.quick_mode { 20 } else { 100 }; @@ -1172,7 +1265,11 @@ fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) { let t0 = Instant::now(); let mut hit_count = 0u32; for _ in 0..n { - let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds), GridStyle::default()); + let key = GridKey::from_grids( + black_box(&grids), + black_box(canvas_bounds), + GridStyle::default(), + ); if should_reuse(state.key.borrow().as_ref(), &key) { hit_count += 1; } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 854dc014b..fb55f5254 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -38,7 +38,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 0bb5e1460..9aa9b8bfc 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml index bcb242dce..d9a7576f1 100644 --- a/crates/plugin-template-api2/Cargo.toml +++ b/crates/plugin-template-api2/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } diff --git a/docs/plugin-template-v2/Cargo.toml b/docs/plugin-template-v2/Cargo.toml index 300cf870a..23a662f1f 100644 --- a/docs/plugin-template-v2/Cargo.toml +++ b/docs/plugin-template-v2/Cargo.toml @@ -12,4 +12,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0" } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c" } diff --git a/src/app/automation.rs b/src/app/automation.rs index 7089fead6..3b53ce982 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -18,7 +18,7 @@ use std::io::{BufRead, Write}; use std::path::PathBuf; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use super::OpenCADStudio; @@ -318,7 +318,9 @@ impl OpenCADStudio { self.tabs[i].scene.clear(); self.tabs[i].scene.document = doc; self.tabs[i].scene.load_named_parameters_from_document(); - self.tabs[i].scene.load_sketch_constraints_from_document(); + self.tabs[i] + .scene + .load_parametric_constraints_from_document(); self.tabs[i].scene.material_base_dir = path_buf.parent().map(PathBuf::from); crate::app::style_ops::ensure_standard_styles( &mut self.tabs[i].scene.document, @@ -1399,7 +1401,8 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); let stale = acadrust::Handle::from(9999); app.tabs[app.active_tab].scene.solid_models.insert( - stale, cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(), + stale, + cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(), ); let path = std::env::temp_dir().join(format!( "ocs_automation_finalize_test_{}.dxf", @@ -1415,7 +1418,8 @@ mod tests { let mut corrupt = acadrust::entities::Circle::new(); corrupt.center = acadrust::types::Vector3::new(1.0, 1.0, 0.0); corrupt.radius = 0.0; // io::is_entity_corrupt rejects a zero-radius circle - doc.add_entity(acadrust::EntityType::Circle(corrupt)).unwrap(); + doc.add_entity(acadrust::EntityType::Circle(corrupt)) + .unwrap(); let bytes = crate::io::save_to_bytes(&doc, "dxf", doc.version) .expect("save a document containing a corrupt entity"); std::fs::write(&path, bytes).unwrap(); @@ -1423,19 +1427,29 @@ mod tests { let p = path.to_string_lossy().replace('\\', "\\\\"); let result = app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#)); assert_eq!(result["ok"], true, "{}", result["error"]); - assert_eq!(result["total"], 1, "the corrupt circle must not survive the open"); - assert_eq!(result["purged"], 1, "the purge count must be reported, matching the UI open path's diagnostics"); + assert_eq!( + result["total"], 1, + "the corrupt circle must not survive the open" + ); + assert_eq!( + result["purged"], 1, + "the purge count must be reported, matching the UI open path's diagnostics" + ); let i = app.active_tab; assert!(!app.tabs[i].scene.solid_models.contains_key(&stale)); - assert_eq!(app.tabs[i].scene.material_base_dir.as_deref(), path.parent()); + assert_eq!( + app.tabs[i].scene.material_base_dir.as_deref(), + path.parent() + ); assert!( app.tabs[i].scene.document.source_path.is_some(), "automation open must run the same finalization as a path-based open, which sets source_path (load_bytes alone never does)" ); app.tabs[i].scene.solid_models.insert( - stale, cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(), + stale, + cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(), ); assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], true); assert!(app.tabs[i].scene.solid_models.is_empty()); @@ -1523,26 +1537,41 @@ mod tests { // Start LINE let _ = app.update(Message::CommandInput("LINE".to_string())); let _ = app.update(Message::CommandSubmit); - assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("LINE") + ); // Type M2P let _ = app.update(Message::CommandInput("M2P".to_string())); let _ = app.update(Message::CommandSubmit); - 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") + ); assert!(app.tabs[0].suspended_cmd.is_some()); - assert_eq!(app.tabs[0].suspended_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert_eq!( + app.tabs[0].suspended_cmd.as_ref().map(|c| c.name()), + Some("LINE") + ); // Point 1: 0,0 let _ = app.update(Message::CommandInput("0,0".to_string())); let _ = app.update(Message::CommandSubmit); - 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") + ); // Point 2: 10,20 let _ = app.update(Message::CommandInput("10,20".to_string())); let _ = app.update(Message::CommandSubmit); // MTP should have finished and restored LINE, with midpoint (5, 10, 0) - assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("LINE") + ); assert!(app.tabs[0].suspended_cmd.is_none()); assert_eq!(app.last_point, Some(glam::DVec3::new(5.0, 10.0, 0.0))); @@ -1561,7 +1590,10 @@ mod tests { let _ = app.update(Message::CommandSubmit); let _ = app.update(Message::SnapOverrideMtp); - 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") + ); assert_eq!( app.tabs[0].suspended_cmd.as_ref().map(|c| c.name()), Some("LINE") @@ -1577,17 +1609,26 @@ mod tests { // Start LINE let _ = app.update(Message::CommandInput("LINE".to_string())); let _ = app.update(Message::CommandSubmit); - assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("LINE") + ); // Type MTP let _ = app.update(Message::CommandInput("MTP".to_string())); let _ = app.update(Message::CommandSubmit); - 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") + ); // Escape during MTP let _ = app.update(Message::CommandEscape); // Parent LINE must be restored, not cancelled! - assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("LINE") + ); assert!(app.tabs[0].suspended_cmd.is_none()); // Escape again cancels LINE @@ -1605,7 +1646,10 @@ mod tests { // Start LINE let _ = app.update(Message::CommandInput("LINE".to_string())); let _ = app.update(Message::CommandSubmit); - assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("LINE") + ); // Simulate typing 'm', '2', 'p' character by character let _ = app.update(Message::CommandAppendChar("m".to_string())); @@ -1620,6 +1664,9 @@ mod tests { // Submit triggers MTP let _ = app.update(Message::CommandSubmit); - 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") + ); } } diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index af720ea76..0600de34d 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -8,9 +8,10 @@ impl OpenCADStudio { let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) else { return false; }; - self.command_line.push_info(crate::tf!( - "Object is on locked layer \"{layer}\" — unlock the layer to edit it." - ).as_ref()); + self.command_line.push_info( + crate::tf!("Object is on locked layer \"{layer}\" — unlock the layer to edit it.") + .as_ref(), + ); true } @@ -42,19 +43,13 @@ impl OpenCADStudio { let endpoint = |entity: &acadrust::EntityType| { let last_grip = match entity { acadrust::EntityType::Line(line) => { - return Some(glam::DVec3::new( - line.end.x, - line.end.y, - line.end.z, - )); + return Some(glam::DVec3::new(line.end.x, line.end.y, line.end.z)); } acadrust::EntityType::Arc(_) => Some(2), acadrust::EntityType::LwPolyline(polyline) => { polyline.vertices.len().checked_sub(1) } - acadrust::EntityType::Polyline(polyline) => { - polyline.vertices.len().checked_sub(1) - } + acadrust::EntityType::Polyline(polyline) => polyline.vertices.len().checked_sub(1), acadrust::EntityType::Polyline2D(polyline) => { polyline.vertices.len().checked_sub(1) } @@ -212,9 +207,7 @@ impl OpenCADStudio { .solid_history_operation(handle) .cloned() .is_some_and(|operation| { - self.tabs[i] - .scene - .rebuild_solid_history(handle, operation) + self.tabs[i].scene.rebuild_solid_history(handle, operation) }); if !restored { self.tabs[i].scene.reseed_derived_caches(handle); @@ -267,8 +260,10 @@ impl OpenCADStudio { if self.tabs[i].active_cmd.is_some() { tasks.push(self.apply_cmd_result(CmdResult::CancelForSpaceChange)); } else if message_pending { - self.command_line - .push_info(crate::t!("Command cancelled because the active drawing space changed.").as_ref()); + self.command_line.push_info( + crate::t!("Command cancelled because the active drawing space changed.") + .as_ref(), + ); } cancellation_reported = true; } @@ -281,8 +276,9 @@ impl OpenCADStudio { self.mtext_cancel(); } if !cancellation_reported && (grip_cancelled || suspended_cancelled || editor_cancelled) { - self.command_line - .push_info(crate::t!("Command cancelled because the active drawing space changed.").as_ref()); + self.command_line.push_info( + crate::t!("Command cancelled because the active drawing space changed.").as_ref(), + ); } self.command_line.input.clear(); @@ -306,7 +302,8 @@ impl OpenCADStudio { .is_some_and(|command| command.name() != "LIMITS") && self.tabs[i].scene.drawing_limit_check_enabled(); if checks_limits && !self.tabs[i].scene.point_inside_drawing_limits(point) { - self.command_line.push_error(crate::t!("Outside limits.").as_ref()); + self.command_line + .push_error(crate::t!("Outside limits.").as_ref()); return false; } true @@ -314,15 +311,24 @@ impl OpenCADStudio { /// Refresh screen-space feature picking immediately before point dispatch. pub(super) fn refresh_command_point_pick_context(&mut self, i: usize) { - if self.tabs[i].active_cmd.as_ref() + if self.tabs[i] + .active_cmd + .as_ref() .is_some_and(|command| command.wants_point_pick_context()) { let scene = &self.tabs[i].scene; let size = scene.selection.borrow().vp_size; let edit = scene.viewport_edit_frame(size); - let tile = edit.as_ref().map(|(_, rect)| *rect) + let tile = edit + .as_ref() + .map(|(_, rect)| *rect) .unwrap_or_else(|| scene.active_model_tile_bounds(size.0, size.1)); - let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: tile.width, height: tile.height }; + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: tile.width, + height: tile.height, + }; let context = if bounds.width > 0.0 && bounds.height > 0.0 { let (view, eye) = if let Some((camera, _)) = edit { (camera.view_proj_rte(bounds), camera.eye()) @@ -330,9 +336,15 @@ impl OpenCADStudio { let camera = scene.camera.borrow(); (camera.view_proj_rte(bounds), camera.eye()) }; - Some(crate::command::PointPickContext { view, eye, bounds, - aperture_px: crate::ui::overlay::pick_box_aperture_px(self.pick_box) }) - } else { None }; + Some(crate::command::PointPickContext { + view, + eye, + bounds, + aperture_px: crate::ui::overlay::pick_box_aperture_px(self.pick_box), + }) + } else { + None + }; if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.set_point_pick_context(context); } @@ -406,7 +418,9 @@ impl OpenCADStudio { if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.set_entity_pick_direction(direction); } - if self.tabs[i].active_cmd.as_ref() + if self.tabs[i] + .active_cmd + .as_ref() .is_some_and(|command| command.inject_before_entity_pick()) { if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() { @@ -417,7 +431,9 @@ impl OpenCADStudio { } } if let StepInput::SelectionComplete(handles) = &input { - let exclude_locked = self.tabs[i].active_cmd.as_ref() + let exclude_locked = self.tabs[i] + .active_cmd + .as_ref() .is_some_and(|command| command.selection_entities_exclude_locked()); let entities = { let scene = &self.tabs[i].scene; @@ -444,7 +460,9 @@ impl OpenCADStudio { command.inject_selection_entities(entities); } } - if matches!(&input, StepInput::Point(_)) { self.refresh_command_point_pick_context(i); } + if matches!(&input, StepInput::Point(_)) { + self.refresh_command_point_pick_context(i); + } let ctrl = self.ctrl_down; let shift = self.shift_down; let result: Option = { @@ -611,11 +629,14 @@ impl OpenCADStudio { _ => return None, }; if add.is_empty() { - self.command_line.push_info(crate::t!(match kw.as_str() { - "P" | "PREVIOUS" => "No previous selection set.", - "ALL" => "Nothing to select.", - _ => "No last object.", - }).as_ref()); + self.command_line.push_info( + crate::t!(match kw.as_str() { + "P" | "PREVIOUS" => "No previous selection set.", + "ALL" => "Nothing to select.", + _ => "No last object.", + }) + .as_ref(), + ); return Some(Task::none()); } let count = add.len(); @@ -790,8 +811,7 @@ impl OpenCADStudio { let tab = &mut self.tabs[self.active_tab]; let changed = tab.scene.document.header.sketch_type != sketch_type || (tab.scene.document.header.sketch_increment - increment).abs() > f64::EPSILON - || (tab.scene.document.header.sketch_tolerance - tolerance).abs() - > f64::EPSILON; + || (tab.scene.document.header.sketch_tolerance - tolerance).abs() > f64::EPSILON; if changed { crate::io::set_sketch_settings( &mut tab.scene.document, @@ -947,12 +967,18 @@ impl OpenCADStudio { } /// Removes and re-solves the first conflicting constraint in the active scope. - pub(super) fn resolve_one_sketch_conflict(&mut self) { + pub(super) fn resolve_one_parametric_conflict(&mut self) { let i = self.active_tab; - let scope = self.tabs[i].current_sketch_scope(); - let Some(set) = self.tabs[i].scene.sketch_constraint_set(scope) else { return }; - let Some(&(id, _kind)) = set.conflicts.first() else { return }; - let Some(constraint) = set.get(id) else { return }; + let scope = self.tabs[i].current_parametric_scope(); + let Some(set) = self.tabs[i].scene.parametric_constraint_set(scope) else { + return; + }; + let Some(&(id, _kind)) = set.conflicts.first() else { + return; + }; + let Some(constraint) = set.get(id) else { + return; + }; let touched: Vec = constraint.refs.iter().map(|r| r.entity).collect(); let label = "Remove conflicting constraint"; @@ -960,10 +986,15 @@ impl OpenCADStudio { let pending = self.begin_undo(i, label, touched.len(), true); self.tabs[i] .scene - .record_undo_sketch_constraints_before(scope, constraints_before); - self.tabs[i].scene.sketch_constraint_set_mut(scope).remove(id); - let changes: Vec<(Handle, crate::scene::ChangeKind)> = - touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); + .record_undo_parametric_constraints_before(scope, constraints_before); + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .remove(id); + let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + .into_iter() + .map(|h| (h, crate::scene::ChangeKind::Modified)) + .collect(); self.tabs[i].scene.bump_entities(&changes); if let Some(pd) = pending { self.commit_undo_delta(i, pd); @@ -978,7 +1009,9 @@ impl OpenCADStudio { // one arbitrarily (`ParameterTable::set` would just let the last // one silently win) -- refuse every row sharing that name up front, // same as `named_parameters::preview`'s live check. - let duplicates = crate::ui::window::named_parameters::duplicate_name_rows(&self.named_parameter_editor_rows); + let duplicates = crate::ui::window::named_parameters::duplicate_name_rows( + &self.named_parameter_editor_rows, + ); let mut table = crate::scene::named_parameters::ParameterTable::new(); let mut failed = 0usize; for (idx, row) in self.named_parameter_editor_rows.iter().enumerate() { @@ -987,12 +1020,19 @@ impl OpenCADStudio { continue; } if let Err(e) = table.set(name, row.formula.trim()) { - self.command_line.push_error(crate::tf!("Parameter '{}': {}", name, e).as_ref()); + self.command_line + .push_error(crate::tf!("Parameter '{}': {}", name, e).as_ref()); failed += 1; } } if !duplicates.is_empty() { - self.command_line.push_error(crate::tf!("{} row(s) skipped: duplicate parameter name.", duplicates.len()).as_ref()); + self.command_line.push_error( + crate::tf!( + "{} row(s) skipped: duplicate parameter name.", + duplicates.len() + ) + .as_ref(), + ); failed += duplicates.len(); } let param_count = table.len(); @@ -1004,10 +1044,16 @@ impl OpenCADStudio { // incremental" philosophy). let touched: Vec = self.tabs[i] .scene - .sketch_constraints + .parametric_constraints .iter() .flat_map(|set| set.constraints.iter()) - .filter(|c| c.enabled && matches!(c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(_)))) + .filter(|c| { + c.enabled + && matches!( + c.driving_param, + Some(crate::scene::named_parameters::DrivingValue::Named(_)) + ) + }) .flat_map(|c| c.refs.iter().map(|r| r.entity)) .collect(); @@ -1016,15 +1062,20 @@ impl OpenCADStudio { self.tabs[i].scene.named_parameters = table; self.tabs[i].dirty = true; if !touched.is_empty() { - let changes: Vec<(Handle, crate::scene::ChangeKind)> = - touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); + let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + .into_iter() + .map(|h| (h, crate::scene::ChangeKind::Modified)) + .collect(); self.tabs[i].scene.bump_entities(&changes); + } else { + self.tabs[i].scene.sync_native_parametric_graph(); } if let Some(pd) = pending { self.commit_undo_delta(i, pd); } if failed == 0 { - self.command_line.push_info(crate::tf!("{} parameter(s) applied.", param_count).as_ref()); + self.command_line + .push_info(crate::tf!("{} parameter(s) applied.", param_count).as_ref()); } } @@ -1037,7 +1088,7 @@ impl OpenCADStudio { fn resolve_named_parameter_edit(&mut self, i: usize, name: &str) { let touched: Vec = self.tabs[i] .scene - .sketch_constraints + .parametric_constraints .iter() .flat_map(|set| set.constraints.iter()) .filter(|c| { @@ -1048,10 +1099,13 @@ impl OpenCADStudio { .collect(); self.tabs[i].dirty = true; if touched.is_empty() { + self.tabs[i].scene.sync_native_parametric_graph(); return; } - let changes: Vec<(Handle, crate::scene::ChangeKind)> = - touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); + let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + .into_iter() + .map(|h| (h, crate::scene::ChangeKind::Modified)) + .collect(); self.tabs[i].scene.bump_entities(&changes); } @@ -1080,7 +1134,13 @@ impl OpenCADStudio { return Task::none(); }; let typed = typed.trim().to_string(); - let Some(current) = self.tabs[i].scene.named_parameters().iter().nth(index).cloned() else { + let Some(current) = self.tabs[i] + .scene + .named_parameters() + .iter() + .nth(index) + .cloned() + else { self.refresh_properties(); return Task::none(); }; @@ -1095,7 +1155,8 @@ impl OpenCADStudio { 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.command_line + .push_error(crate::tf!("Parameter '{}' already exists.", typed).as_ref()); self.refresh_properties(); return Task::none(); } @@ -1106,13 +1167,27 @@ impl OpenCADStudio { self.tabs[i].scene.record_undo_named_parameters_before(); let (result, resolve_name) = match field { - ParamField::Formula => - (self.tabs[i].scene.named_parameters_mut().set(¤t.name, &typed), current.name.clone()), + ParamField::Formula => ( + self.tabs[i] + .scene + .named_parameters_mut() + .set(¤t.name, &typed), + current.name.clone(), + ), ParamField::Name => { - self.tabs[i].scene.named_parameters_mut().remove(¤t.name); - let outcome = self.tabs[i].scene.named_parameters_mut().set(&typed, ¤t.source); + self.tabs[i] + .scene + .named_parameters_mut() + .remove(¤t.name); + let outcome = self.tabs[i] + .scene + .named_parameters_mut() + .set(&typed, ¤t.source); if outcome.is_err() { - let _ = self.tabs[i].scene.named_parameters_mut().set(¤t.name, ¤t.source); + let _ = self.tabs[i] + .scene + .named_parameters_mut() + .set(¤t.name, ¤t.source); } (outcome, typed.clone()) } @@ -1120,7 +1195,8 @@ impl OpenCADStudio { if let Err(e) = result { self.tabs[i].scene.take_undo_recording(); - self.command_line.push_error(crate::tf!("Parameter '{}': {}", current.name, e).as_ref()); + self.command_line + .push_error(crate::tf!("Parameter '{}': {}", current.name, e).as_ref()); self.refresh_properties(); return Task::none(); } @@ -1128,7 +1204,7 @@ impl OpenCADStudio { if field == ParamField::Name { let affected: Vec = self.tabs[i] .scene - .sketch_constraints + .parametric_constraints .iter() .enumerate() .filter_map(|(index, set)| { @@ -1141,11 +1217,17 @@ impl OpenCADStudio { }) .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 { + let scope = self.tabs[i].scene.parametric_constraints[set_index].scope; + let before = self.tabs[i].scene.parametric_constraints[set_index].clone(); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, before); + for constraint in + &mut self.tabs[i].scene.parametric_constraints[set_index].constraints + { + if let Some(crate::scene::named_parameters::DrivingValue::Named(name)) = + &mut constraint.driving_param + { if name == ¤t.name { *name = typed.clone(); } @@ -1168,7 +1250,13 @@ impl OpenCADStudio { /// special-case handling here. pub(super) fn on_prop_param_delete(&mut self, index: usize) -> Task { let i = self.active_tab; - let Some(name) = self.tabs[i].scene.named_parameters().iter().nth(index).map(|p| p.name.clone()) else { + let Some(name) = self.tabs[i] + .scene + .named_parameters() + .iter() + .nth(index) + .map(|p| p.name.clone()) + else { return Task::none(); }; let pending = self.begin_undo(i, "Delete named parameter", 0, true); @@ -1206,6 +1294,7 @@ impl OpenCADStudio { let pending = self.begin_undo(i, "Add named parameter", 0, true); self.tabs[i].scene.record_undo_named_parameters_before(); let _ = self.tabs[i].scene.named_parameters_mut().set(&name, "1"); + self.tabs[i].scene.sync_native_parametric_graph(); self.tabs[i].dirty = true; if let Some(pd) = pending { self.commit_undo_delta(i, pd); @@ -1234,25 +1323,47 @@ impl OpenCADStudio { handle: Handle, mut entities: Vec, ) -> Vec { - let same_type = entities.len() == 1 && self.tabs[tab].scene.document.get_entity(handle) - .is_some_and(|original| std::mem::discriminant(original) == std::mem::discriminant(&entities[0])); + let same_type = entities.len() == 1 + && self.tabs[tab] + .scene + .document + .get_entity(handle) + .is_some_and(|original| { + std::mem::discriminant(original) == std::mem::discriminant(&entities[0]) + }); let handles = if same_type { let mut entity = entities.pop().expect("one replacement"); entity.common_mut().handle = handle; // Scene::update_entity also retains the live owning block and // refreshes dependent render caches without erase notifications. - if self.tabs[tab].scene.update_entity(entity) { vec![handle] } else { Vec::new() } + if self.tabs[tab].scene.update_entity(entity) { + vec![handle] + } else { + Vec::new() + } } else { - let owner = self.tabs[tab].scene.document.get_entity(handle).map(|entity| entity.common().owner_handle); + let owner = self.tabs[tab] + .scene + .document + .get_entity(handle) + .map(|entity| entity.common().owner_handle); self.tabs[tab].scene.erase_entities(&[handle]); - entities.into_iter().map(|mut entity| { - entity.common_mut().handle = Handle::NULL; - if let Some(owner) = owner { entity.common_mut().owner_handle = owner; } - self.tabs[tab].scene.add_entity(entity) - }).collect() + entities + .into_iter() + .map(|mut entity| { + entity.common_mut().handle = Handle::NULL; + if let Some(owner) = owner { + entity.common_mut().owner_handle = owner; + } + self.tabs[tab].scene.add_entity(entity) + }) + .collect() }; for &updated in &handles { - if matches!(self.tabs[tab].scene.document.get_entity(updated), Some(acadrust::EntityType::Dimension(_))) { + if matches!( + self.tabs[tab].scene.document.get_entity(updated), + Some(acadrust::EntityType::Dimension(_)) + ) { self.tabs[tab].scene.invalidate_dim_block_recorded(updated); } } @@ -1270,8 +1381,7 @@ impl OpenCADStudio { if !matches!( &op, PeditOp::Multiple(_, _) | PeditOp::JoinSelection(_, _, _) - ) - && self.reject_locked_edit(tab, handle) + ) && self.reject_locked_edit(tab, handle) { return Task::none(); } @@ -1291,7 +1401,10 @@ impl OpenCADStudio { .map(|entity| (*handle, entity)) }) .collect::>(); - let connector_distance = crate::modules::draw::modify::pedit::selection_connector_distance(&available, *fuzz); + let connector_distance = + crate::modules::draw::modify::pedit::selection_connector_distance( + &available, *fuzz, + ); let mut changes = Vec::new(); while !available.is_empty() { let (source_handle, source) = available.remove(0); @@ -1343,8 +1456,11 @@ impl OpenCADStudio { .map(|replacement| (*handle, replacement, true)) } else { let mut replacement = original.clone(); - apply_pedit(&mut replacement, operation) - .then_some((*handle, replacement, false)) + apply_pedit(&mut replacement, operation).then_some(( + *handle, + replacement, + false, + )) } }) .collect(); @@ -1426,14 +1542,12 @@ impl OpenCADStudio { .and_then(convert_to_polyline); match converted { Some(polyline) => { - return self.apply_cmd_result(CmdResult::ReplaceEntity( - handle, - vec![polyline], - )); + return self + .apply_cmd_result(CmdResult::ReplaceEntity(handle, vec![polyline])); } - None => self.command_line.push_error( - crate::t!("PEDIT: cannot convert this entity.").as_ref(), - ), + None => self + .command_line + .push_error(crate::t!("PEDIT: cannot convert this entity.").as_ref()), } } _ => { @@ -1468,7 +1582,11 @@ impl OpenCADStudio { } } } - if let Some(prompt) = self.tabs[tab].active_cmd.as_ref().map(|command| command.prompt()) { + if let Some(prompt) = self.tabs[tab] + .active_cmd + .as_ref() + .map(|command| command.prompt()) + { self.command_line.push_info(&prompt); } Task::none() @@ -1476,18 +1594,24 @@ impl OpenCADStudio { fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; - if let Some(bind) = self.tabs[i].active_cmd.as_ref().and_then(|command| command.nested_copy_bind_setting()) { + if let Some(bind) = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|command| command.nested_copy_bind_setting()) + { if self.ncopy_bind != bind { self.ncopy_bind = bind; self.persist_settings_if_changed(); } } - let preserve_commit_style = self.tabs[i].active_cmd.as_ref() + let preserve_commit_style = self.tabs[i] + .active_cmd + .as_ref() .is_some_and(|command| command.preserve_commit_style()); let preserve_commit_layer = self.tabs[i] - .active_cmd - .as_ref() - .is_some_and(|command| command.preserve_commit_layer()); + .active_cmd + .as_ref() + .is_some_and(|command| command.preserve_commit_layer()); match result { CmdResult::NeedPoint => { // ATTEDIT finished its entity pick: hand the chosen block off to @@ -1590,9 +1714,7 @@ impl OpenCADStudio { }; if is_associative_dimension && association_enabled { if let Some(handle) = committed { - let sources = self.tabs[i] - .scene - .infer_dimension_sources(handle); + let sources = self.tabs[i].scene.infer_dimension_sources(handle); self.tabs[i] .scene .attach_dimension_association(handle, sources); @@ -1620,14 +1742,19 @@ impl OpenCADStudio { return Task::none(); } let label = self.history_label_from_active_cmd(i, "ENTITY"); - let symbol_names = self.tabs[i].active_cmd.as_ref() - .and_then(|command| command.nested_copy_symbol_names()).cloned(); - let delta_safe = symbol_names.is_none() && entities - .iter() - .all(|entity| self.delta_add_safe(i, entity)); + let symbol_names = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|command| command.nested_copy_symbol_names()) + .cloned(); + let delta_safe = symbol_names.is_none() + && entities.iter().all(|entity| self.delta_add_safe(i, entity)); let pending = self.begin_undo(i, label, entities.len(), delta_safe); if let Some(names) = symbol_names { - let retained = self.tabs[i].scene.document.localize_nested_copy_symbols(&mut entities, &names); + let retained = self.tabs[i] + .scene + .document + .localize_nested_copy_symbols(&mut entities, &names); if retained > 0 { self.command_line.push_info(&format!("{} copied object(s) retain imported style references whose dependencies cannot be localized.", retained)); } @@ -1654,14 +1781,19 @@ impl OpenCADStudio { } CmdResult::CommitEntitiesAndExit(mut entities) => { let label = self.history_label_from_active_cmd(i, "ENTITY"); - let symbol_names = self.tabs[i].active_cmd.as_ref() - .and_then(|command| command.nested_copy_symbol_names()).cloned(); - let delta_safe = symbol_names.is_none() && entities - .iter() - .all(|entity| self.delta_add_safe(i, entity)); + let symbol_names = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|command| command.nested_copy_symbol_names()) + .cloned(); + let delta_safe = symbol_names.is_none() + && entities.iter().all(|entity| self.delta_add_safe(i, entity)); let pending = self.begin_undo(i, label, entities.len(), delta_safe); if let Some(names) = symbol_names { - let retained = self.tabs[i].scene.document.localize_nested_copy_symbols(&mut entities, &names); + let retained = self.tabs[i] + .scene + .document + .localize_nested_copy_symbols(&mut entities, &names); if retained > 0 { self.command_line.push_info(&format!("{} copied object(s) retain imported style references whose dependencies cannot be localized.", retained)); } @@ -1703,9 +1835,7 @@ impl OpenCADStudio { }); let label = self.history_label_from_active_cmd(i, "MVIEW"); let pending = self.begin_undo(i, label, 1, true); - let handle = self.commit_entity_handle( - acadrust::EntityType::Viewport(viewport), - ); + let handle = self.commit_entity_handle(acadrust::EntityType::Viewport(viewport)); if let (Some(handle), Some(saved)) = (handle, saved_view) { if let Some(acadrust::EntityType::Viewport(viewport)) = self.tabs[i].scene.document.get_entity_mut(handle) @@ -1736,36 +1866,27 @@ impl OpenCADStudio { } => { if boundary.is_none() { let scene = &self.tabs[i].scene; - let valid = scene - .entity_belongs_to_current_layout(boundary_handle) + let valid = scene.entity_belongs_to_current_layout(boundary_handle) && scene - .document - .get_entity(boundary_handle) - .is_some_and(|entity| { - match entity { + .document + .get_entity(boundary_handle) + .is_some_and(|entity| match entity { acadrust::EntityType::Circle(_) => true, acadrust::EntityType::Ellipse(ellipse) => ellipse.is_full(), - acadrust::EntityType::LwPolyline(polyline) => { - polyline.is_closed - } - acadrust::EntityType::Polyline(polyline) => { - polyline.is_closed() - } - acadrust::EntityType::Polyline2D(polyline) => { - polyline.is_closed() - } - acadrust::EntityType::Polyline3D(polyline) => { - polyline.flags.closed - } + acadrust::EntityType::LwPolyline(polyline) => polyline.is_closed, + acadrust::EntityType::Polyline(polyline) => polyline.is_closed(), + acadrust::EntityType::Polyline2D(polyline) => polyline.is_closed(), + acadrust::EntityType::Polyline3D(polyline) => polyline.flags.closed, _ => false, - } - }); + }); if !valid { self.command_line.push_error( crate::t!("MVIEW Object: select a closed paper-space circle, ellipse, or polyline.").as_ref(), ); - if let Some(prompt) = - self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) + if let Some(prompt) = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.prompt()) { self.command_line.push_info(&prompt); } @@ -1798,9 +1919,7 @@ impl OpenCADStudio { } None => boundary_handle, }; - let polygon = self.tabs[i] - .scene - .clip_boundary_polygon(clip_handle, 0.0); + let polygon = self.tabs[i].scene.clip_boundary_polygon(clip_handle, 0.0); let bounds: Option<(f64, f64, f64, f64)> = polygon.iter().fold(None, |bounds, point| { if !point[0].is_finite() || !point[1].is_finite() { @@ -1822,8 +1941,9 @@ impl OpenCADStudio { }) }); let Some((min_x, min_y, max_x, max_y)) = bounds else { - self.command_line - .push_error(crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref()); + self.command_line.push_error( + crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref(), + ); self.tabs[i].active_cmd = None; if let Some(pending) = pending { self.commit_undo_delta(i, pending); @@ -1831,8 +1951,9 @@ impl OpenCADStudio { return Task::none(); }; if max_x - min_x < 1e-6 || max_y - min_y < 1e-6 { - self.command_line - .push_error(crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref()); + self.command_line.push_error( + crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref(), + ); self.tabs[i].active_cmd = None; if let Some(pending) = pending { self.commit_undo_delta(i, pending); @@ -1850,9 +1971,8 @@ impl OpenCADStudio { viewport.height = max_y - min_y; viewport.id = 2; viewport.clip_boundary_handle = clip_handle; - let viewport_handle = self.commit_entity_handle( - acadrust::EntityType::Viewport(viewport), - ); + let viewport_handle = + self.commit_entity_handle(acadrust::EntityType::Viewport(viewport)); if let Some(viewport_handle) = viewport_handle { if !created_boundary { let before = self.tabs[i] @@ -1861,12 +1981,9 @@ impl OpenCADStudio { .get_entity(clip_handle) .cloned() .map(std::sync::Arc::new); - self.tabs[i] - .scene - .record_undo_before(clip_handle, before); + self.tabs[i].scene.record_undo_before(clip_handle, before); } - if let Some(boundary) = - self.tabs[i].scene.document.get_entity_mut(clip_handle) + if let Some(boundary) = self.tabs[i].scene.document.get_entity_mut(clip_handle) { let common = boundary.common_mut(); common.invisible = true; @@ -1874,10 +1991,9 @@ impl OpenCADStudio { common.reactors.push(viewport_handle); } } - self.tabs[i].scene.bump_entities(&[( - clip_handle, - crate::scene::ChangeKind::Modified, - )]); + self.tabs[i] + .scene + .bump_entities(&[(clip_handle, crate::scene::ChangeKind::Modified)]); } self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); @@ -1898,9 +2014,7 @@ impl OpenCADStudio { .entity_belongs_to_active_space(handle) .then(|| scene.document.get_entity(handle)) .flatten() - .and_then( - crate::modules::draw::draw::wipeout::wipeout_from_polyline, - ) + .and_then(crate::modules::draw::draw::wipeout::wipeout_from_polyline) }; if let Some(wipeout) = wipeout { if erase_source { @@ -1914,17 +2028,17 @@ impl OpenCADStudio { self.command_line.push_error( crate::t!("WIPEOUT Polyline: select a straight, closed, planar 2D polyline with at least 3 non-intersecting vertices.").as_ref(), ); - let command = - crate::modules::draw::draw::wipeout::WipeoutCommand::new_polyline(); - self.command_line.push_info( - &crate::command::CadCommand::prompt(&command), - ); + let command = crate::modules::draw::draw::wipeout::WipeoutCommand::new_polyline(); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); self.tabs[i].active_cmd = Some(Box::new(command)); } CmdResult::MviewSwitchLayout(layout) => { let task = self.on_layout_switch_preserving_command(layout); - if let Some(prompt) = - self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) + if let Some(prompt) = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.prompt()) { self.command_line.push_info(&prompt); } @@ -1987,10 +2101,8 @@ impl OpenCADStudio { } CmdResult::CopyToClipboard { handles, base } => { let count = self.copy_entities_to_clipboard(i, &handles, base); - self.command_line.push_info(crate::tf!( - "{} object(s) copied to clipboard.", - count - ).as_ref()); + self.command_line + .push_info(crate::tf!("{} object(s) copied to clipboard.", count).as_ref()); let prompt = self.tabs[i] .active_cmd .as_ref() @@ -2063,9 +2175,7 @@ impl OpenCADStudio { let committed = self.commit_entity_handle(entity); if is_associative_dimension && association_enabled { if let Some(handle) = committed { - let sources = self.tabs[i] - .scene - .infer_dimension_sources(handle); + let sources = self.tabs[i].scene.infer_dimension_sources(handle); self.tabs[i] .scene .attach_dimension_association(handle, sources); @@ -2090,16 +2200,10 @@ impl OpenCADStudio { continue_command, } => { let label = self.history_label_from_active_cmd(i, "DIMENSION"); - let association_mode = self.tabs[i] - .scene - .document - .header - .dimension_associativity; + let association_mode = self.tabs[i].scene.document.header.dimension_associativity; let single_source_dimension = matches!( &entity, - acadrust::EntityType::Dimension( - acadrust::entities::Dimension::Ordinate(_) - ) + acadrust::EntityType::Dimension(acadrust::entities::Dimension::Ordinate(_)) ); let inherited_dimension = if preserve_base_style { match &entity { @@ -2152,9 +2256,7 @@ impl OpenCADStudio { &entity, &self.tabs[i].scene.document, ); - let delta_safe = pieces - .iter() - .all(|piece| self.delta_add_safe(i, piece)); + let delta_safe = pieces.iter().all(|piece| self.delta_add_safe(i, piece)); let pending = self.begin_undo(i, label, pieces.len(), delta_safe); for piece in pieces { self.tabs[i].scene.add_entity(piece); @@ -2163,22 +2265,15 @@ impl OpenCADStudio { } else { let delta_safe = self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); - if let Some(handle) = self.commit_entity_handle_with_dimension_policy( - entity, - preserve_base_style, - ) { + if let Some(handle) = + self.commit_entity_handle_with_dimension_policy(entity, preserve_base_style) + { if association_mode == 2 { - let mut changes = vec![ - (handle, crate::scene::ChangeKind::Modified), - ]; + let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)]; match association { crate::command::DimensionAssociationInput::Infer(source) => { let sources: Vec<_> = source.map_or_else( - || { - self.tabs[i] - .scene - .infer_dimension_sources(handle) - }, + || self.tabs[i].scene.infer_dimension_sources(handle), |source| { if single_source_dimension { vec![Some(source)] @@ -2195,9 +2290,10 @@ impl OpenCADStudio { })); } crate::command::DimensionAssociationInput::Explicit(sources) => { - self.tabs[i] - .scene - .attach_dimension_association_sources(handle, sources.clone()); + self.tabs[i].scene.attach_dimension_association_sources( + handle, + sources.clone(), + ); changes.extend(sources.into_iter().flatten().map(|source| { (source.handle, crate::scene::ChangeKind::Modified) })); @@ -2217,11 +2313,7 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } if continue_command { - if let Some(prompt) = self.tabs[i] - .active_cmd - .as_ref() - .map(|cmd| cmd.prompt()) - { + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|cmd| cmd.prompt()) { self.command_line.push_info(&prompt); } } else { @@ -2230,11 +2322,7 @@ impl OpenCADStudio { } } CmdResult::CommitDimensionsAndExit(dimensions) => { - let association_mode = self.tabs[i] - .scene - .document - .header - .dimension_associativity; + let association_mode = self.tabs[i].scene.document.header.dimension_associativity; let mut made = 0; let pending = if association_mode == 0 { let mut pieces = Vec::new(); @@ -2277,9 +2365,7 @@ impl OpenCADStudio { } pieces.extend(exploded); } - let delta_safe = pieces - .iter() - .all(|piece| self.delta_add_safe(i, piece)); + let delta_safe = pieces.iter().all(|piece| self.delta_add_safe(i, piece)); let pending = self.begin_undo(i, "QDIM", pieces.len(), delta_safe); for piece in pieces { self.tabs[i].scene.add_entity(piece); @@ -2330,9 +2416,12 @@ impl OpenCADStudio { .scene .attach_dimension_association_sources(handle, sources.clone()); let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)]; - changes.extend(sources.into_iter().flatten().map(|source| { - (source.handle, crate::scene::ChangeKind::Modified) - })); + changes.extend( + sources + .into_iter() + .flatten() + .map(|source| (source.handle, crate::scene::ChangeKind::Modified)), + ); changes.sort_by_key(|(handle, _)| handle.value()); changes.dedup_by_key(|(handle, _)| handle.value()); self.tabs[i].scene.bump_entities(&changes); @@ -2355,7 +2444,10 @@ impl OpenCADStudio { CmdResult::SetQuickDimensionSnapPriority(priority) => { self.quick_dimension_snap_priority = priority.min(1); self.persist_settings_if_changed(); - let prompt = self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()); + let prompt = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.prompt()); if let Some(prompt) = prompt { self.command_line.push_info(&prompt); } @@ -2376,9 +2468,8 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "MLEADERALIGN"); if apply_mleader_align(&mut self.tabs[i].scene, &handles, from, to) { self.tabs[i].dirty = true; - self.command_line.push_output( - crate::t!("MLEADERALIGN Leaders aligned.").as_ref(), - ); + self.command_line + .push_output(crate::t!("MLEADERALIGN Leaders aligned.").as_ref()); } } self.tabs[i].scene.clear_preview_wire(); @@ -2392,9 +2483,8 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "MLEADERCOLLECT"); if apply_mleader_collect(&mut self.tabs[i].scene, &compatible, point) { self.tabs[i].dirty = true; - self.command_line.push_output( - crate::t!("MLEADERCOLLECT Leaders collected.").as_ref(), - ); + self.command_line + .push_output(crate::t!("MLEADERCOLLECT Leaders collected.").as_ref()); } } self.tabs[i].scene.clear_preview_wire(); @@ -2410,15 +2500,10 @@ impl OpenCADStudio { } => { let label = self.history_label_from_active_cmd(i, "SOLID"); let erase_source = erase_source.filter(|handle| { - self.delete_objects != 0 - && !self.tabs[i].scene.is_layer_locked(*handle) + self.delete_objects != 0 && !self.tabs[i].scene.is_layer_locked(*handle) }); - let pending = self.begin_undo( - i, - label, - 1 + usize::from(erase_source.is_some()), - true, - ); + let pending = + self.begin_undo(i, label, 1 + usize::from(erase_source.is_some()), true); let handle = self.add_solid_model(entity, *solid, history); if !handle.is_null() { if let Some(source) = erase_source { @@ -2477,9 +2562,7 @@ impl OpenCADStudio { open_editor, } => { let label = self.history_label_from_active_cmd(i, "ENTITY"); - let delta_safe = entities - .iter() - .all(|entity| self.delta_add_safe(i, entity)); + let delta_safe = entities.iter().all(|entity| self.delta_add_safe(i, entity)); let pending = self.begin_undo(i, label, entities.len(), delta_safe); let mut edit_handle = None; let mut leader_handle = None; @@ -2509,14 +2592,11 @@ impl OpenCADStudio { // The LEADER may already have received its annotation context while // annotation_handle was still NULL. Refresh it now that the MTEXT link // is known so the context represents the finished leader. + self.tabs[i].scene.sync_displayed_annotation_context(lh); + self.tabs[i] .scene - .sync_displayed_annotation_context(lh); - - self.tabs[i].scene.bump_entities(&[( - lh, - crate::scene::ChangeKind::Modified, - )]); + .bump_entities(&[(lh, crate::scene::ChangeKind::Modified)]); } } self.tabs[i].dirty = true; @@ -2549,15 +2629,12 @@ impl OpenCADStudio { let ucs = self.tabs[i].ucs_xform(); let world_to_block = ucs.to_ucs_transform_at(base); let block_to_world = ucs.to_wcs_transform_at(base); - match self.tabs[i] - .scene - .create_block_from_entities( - &handles, - &name, - &world_to_block, - &block_to_world, - ) - { + match self.tabs[i].scene.create_block_from_entities( + &handles, + &name, + &world_to_block, + &block_to_world, + ) { Ok(insert_handle) => { self.tabs[i].dirty = true; self.tabs[i].scene.deselect_all(); @@ -2607,11 +2684,10 @@ impl OpenCADStudio { let label = self.history_label_from_active_cmd(i, "HATCH"); let pending = self.begin_undo(i, label, 1, true); let layer = self.tabs[i].active_layer.clone(); - let new_handle = self.tabs[i].scene.add_hatch( - hatch, - Some(&layer), - Some((color, transparency)), - ); + let new_handle = + self.tabs[i] + .scene + .add_hatch(hatch, Some(&layer), Some((color, transparency))); if !new_handle.is_null() { self.tabs[i].scene.select_entity(new_handle, true); } @@ -2649,10 +2725,9 @@ impl OpenCADStudio { } hatch.boundary_sources = Some(std::sync::Arc::new(sources)); let layer = self.tabs[i].active_layer.clone(); - let new_handle = - self.tabs[i] - .scene - .add_hatch(hatch, Some(&layer), entity_style); + let new_handle = self.tabs[i] + .scene + .add_hatch(hatch, Some(&layer), entity_style); if !new_handle.is_null() { self.tabs[i].scene.select_entity(new_handle, true); } @@ -2674,11 +2749,10 @@ impl OpenCADStudio { let pending = self.begin_undo(i, label, hatches.len(), true); let layer = self.tabs[i].active_layer.clone(); for hatch in hatches { - let new_handle = self.tabs[i].scene.add_hatch( - hatch, - Some(&layer), - entity_style.clone(), - ); + let new_handle = + self.tabs[i] + .scene + .add_hatch(hatch, Some(&layer), entity_style.clone()); if !new_handle.is_null() { self.tabs[i].scene.select_entity(new_handle, true); } @@ -2737,7 +2811,7 @@ impl OpenCADStudio { .is_some_and(|c| c.name() == "SS_CATCHMENT"); self.push_undo_snapshot(i, label); for (handle, entities) in replacements { - self.replace_command_entity(i,handle,entities); + self.replace_command_entity(i, handle, entities); } for entity in additions { self.tabs[i].scene.add_entity(entity); @@ -2763,7 +2837,7 @@ impl OpenCADStudio { let label = self.history_label_from_active_cmd(i, "TRIM"); self.push_undo_snapshot(i, label); for (handle, entities) in replacements { - let new_handles = self.replace_command_entity(i,handle,entities); + let new_handles = self.replace_command_entity(i, handle, entities); if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.on_entity_replaced(handle, &new_handles); } @@ -2807,31 +2881,40 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; self.refresh_properties(); } - CmdResult::AddSketchConstraint { kind, refs, driving_param, label } => { - if let Err(message) = self.tabs[i] - .scene - .validate_sketch_constraint(kind, &refs, driving_param.as_ref()) - { + CmdResult::AddParametricConstraint { + kind, + refs, + driving_param, + label, + } => { + if let Err(message) = self.tabs[i].scene.validate_parametric_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 constraints_before = self - .tabs[i] + let scope = self.tabs[i].current_parametric_scope(); + let constraints_before = self.tabs[i] .scene - .sketch_constraint_set(scope) + .parametric_constraint_set(scope) .cloned() .unwrap_or_else(|| { - crate::scene::sketch_constraints::SketchConstraintSet::new(scope) + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) }); let touched: Vec = refs.iter().map(|r| r.entity).collect(); let pending = self.begin_undo(i, label, touched.len(), true); self.tabs[i] .scene - .record_undo_sketch_constraints_before(scope, constraints_before); - self.tabs[i].scene.sketch_constraint_set_mut(scope).add(kind, refs, driving_param); + .record_undo_parametric_constraints_before(scope, constraints_before); + self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + kind, + refs, + driving_param, + ); let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched .into_iter() .map(|h| (h, crate::scene::ChangeKind::Modified)) @@ -2846,19 +2929,32 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } - CmdResult::AddCoincidentConstraint { point_a, point_b, label } => { - let scope = self.tabs[i].current_sketch_scope(); + CmdResult::AddCoincidentConstraint { + point_a, + point_b, + label, + } => { + let scope = self.tabs[i].current_parametric_scope(); let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); - let resolved_a = - crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point_a), None); + let resolved_a = crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(point_a), + None, + ); let resolved_b = resolved_a.and_then(|ref_a| { - crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point_b), None) - .filter(|ref_b| *ref_b != ref_a) + crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(point_b), + None, + ) + .filter(|ref_b| *ref_b != ref_a) }); match (resolved_a, resolved_b) { (Some(ref_a), Some(ref_b)) => { - return self.apply_cmd_result(CmdResult::AddSketchConstraint { - kind: crate::scene::sketch_constraints::ConstraintKind::Coincident, + return self.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind::Coincident, refs: vec![ref_a, ref_b], driving_param: None, label, @@ -2873,21 +2969,34 @@ impl OpenCADStudio { } } } - CmdResult::AddPointOnEntityConstraint { point, target, kind, label } => { - let scope = self.tabs[i].current_sketch_scope(); + CmdResult::AddPointOnEntityConstraint { + point, + target, + kind, + label, + } => { + let scope = self.tabs[i].current_parametric_scope(); let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); - let resolved = crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point), Some(target)); + let resolved = crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(point), + Some(target), + ); match resolved { Some(point_ref) => { - use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; // `CenterPoint` addresses its whole-entity side via // the center marker (same solve as Coincident / // Concentric — `ConstraintKind::CenterPoint`'s own // doc comment); `Midpoint`/`PointOnCurve` address it // as a whole entity. - let target_ref = - if kind == ConstraintKind::CenterPoint { SketchRef::center(target) } else { SketchRef::whole(target) }; - return self.apply_cmd_result(CmdResult::AddSketchConstraint { + let target_ref = if kind == ConstraintKind::CenterPoint { + ParametricRef::center(target) + } else { + ParametricRef::whole(target) + }; + return self.apply_cmd_result(CmdResult::AddParametricConstraint { kind, refs: vec![point_ref, target_ref], driving_param: None, @@ -2904,20 +3013,24 @@ impl OpenCADStudio { } } CmdResult::AddEqualDistanceConstraint { points, label } => { - let scope = self.tabs[i].current_sketch_scope(); + let scope = self.tabs[i].current_parametric_scope(); let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); let mut refs = Vec::with_capacity(4); for p in points { - let Some(r) = crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(p), None) - else { + let Some(r) = crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(p), + None, + ) else { refs.clear(); break; }; refs.push(r); } if refs.len() == 4 { - return self.apply_cmd_result(CmdResult::AddSketchConstraint { - kind: crate::scene::sketch_constraints::ConstraintKind::EqualDistance, + return self.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind::EqualDistance, refs, driving_param: None, label, @@ -2929,19 +3042,27 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; } - CmdResult::ReassociateCenterMark { target, source, point } => { + CmdResult::ReassociateCenterMark { + target, + source, + point, + } => { if self.reject_locked_edit(i, target) { return Task::none(); } self.push_undo_snapshot(i, "CENTERREASSOCIATE"); - if self.tabs[i].scene.reassociate_center_mark(target, source, point) { + if self.tabs[i] + .scene + .reassociate_center_mark(target, source, point) + { self.tabs[i].dirty = true; self.command_line.push_output( crate::t!("CENTERREASSOCIATE: center mark associated.").as_ref(), ); } else { self.command_line.push_error( - crate::t!("CENTERREASSOCIATE: the selected source is not circular.").as_ref(), + crate::t!("CENTERREASSOCIATE: the selected source is not circular.") + .as_ref(), ); } self.tabs[i].active_cmd = None; @@ -3077,12 +3198,23 @@ impl OpenCADStudio { self.tabs[i].scene.invalidate_dim_block_recorded(nh); } } - let pedit_entities = if self.tabs[i].active_cmd.as_ref().is_some_and(|command| command.name() == "PEDIT") { - new_handles.iter().filter_map(|new| self.tabs[i].scene.document.get_entity(*new).cloned()).collect::>() - } else { Vec::new() }; + let pedit_entities = if self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.name() == "PEDIT") + { + new_handles + .iter() + .filter_map(|new| self.tabs[i].scene.document.get_entity(*new).cloned()) + .collect::>() + } else { + Vec::new() + }; if let Some(cmd) = &mut self.tabs[i].active_cmd { cmd.on_entity_replaced(handle, &new_handles); - for entity in pedit_entities { cmd.inject_picked_entity(entity); } + for entity in pedit_entities { + cmd.inject_picked_entity(entity); + } } self.tabs[i].dirty = true; let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); @@ -3276,11 +3408,14 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); - self.command_line.push_info(crate::t!(if space_changed { - "Command cancelled because the active drawing space changed." - } else { - "Command cancelled." - }).as_ref()); + self.command_line.push_info( + crate::t!(if space_changed { + "Command cancelled because the active drawing space changed." + } else { + "Command cancelled." + }) + .as_ref(), + ); } } CmdResult::SelectByPath { @@ -3388,7 +3523,9 @@ impl OpenCADStudio { .objects .get(&style_handle) .and_then(|object| match object { - acadrust::objects::ObjectType::TableStyle(style) => Some(style), + acadrust::objects::ObjectType::TableStyle(style) => { + Some(style) + } _ => None, }) }); @@ -3406,15 +3543,13 @@ impl OpenCADStudio { match self.begin_table_cell_edit(i, handle, point) { TableCellEditStart::Started => {} TableCellEditStart::LockedCell => { - self.command_line.push_error( - crate::t!("The cell is content locked.").as_ref(), - ); + self.command_line + .push_error(crate::t!("The cell is content locked.").as_ref()); let _ = self.dispatch_command("TABLEDIT"); } TableCellEditStart::NoCell => { - self.command_line.push_error( - crate::t!("No editable table cell picked.").as_ref(), - ); + self.command_line + .push_error(crate::t!("No editable table cell picked.").as_ref()); let _ = self.dispatch_command("TABLEDIT"); } } @@ -3451,7 +3586,8 @@ impl OpenCADStudio { .push_info(crate::tf!("Layer matched to \"{layer}\".").as_ref()); self.sync_ribbon_layers(); } else { - self.command_line.push_error(crate::t!("Source object not found.").as_ref()); + self.command_line + .push_error(crate::t!("Source object not found.").as_ref()); } } CmdResult::MatchProperties { mut dest, src } => { @@ -3588,15 +3724,17 @@ impl OpenCADStudio { .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); self.tabs[i].scene.bump_entities(&changes); - self.command_line - .push_info(crate::tf!("Properties matched to {} object(s).", dest.len()).as_ref()); + self.command_line.push_info( + crate::tf!("Properties matched to {} object(s).", dest.len()).as_ref(), + ); // Clear the consumed target selection and keep prompting. self.tabs[i].scene.deselect_all(); if let Some(cmd) = &self.tabs[i].active_cmd { self.command_line.push_info(&cmd.prompt()); } } else { - self.command_line.push_error(crate::t!("Source object not found.").as_ref()); + self.command_line + .push_error(crate::t!("Source object not found.").as_ref()); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); @@ -3607,7 +3745,8 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); if self.clipboard.is_empty() { - self.command_line.push_error(crate::t!("Clipboard is empty.").as_ref()); + self.command_line + .push_error(crate::t!("Clipboard is empty.").as_ref()); } else { let delta = base_pt - self.clipboard_base; let translate = crate::command::EntityTransform::Translate(delta); @@ -3739,14 +3878,16 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "VPLAYER"); self.tabs[i].dirty = true; if frozen_count > 0 { - self.command_line.push_info(crate::tf!( - "VPLAYER: {frozen_count} layer(s) frozen in viewport." - ).as_ref()); + self.command_line.push_info( + crate::tf!("VPLAYER: {frozen_count} layer(s) frozen in viewport.") + .as_ref(), + ); } if thawed_count > 0 { - self.command_line.push_info(crate::tf!( - "VPLAYER: {thawed_count} layer(s) thawed in viewport." - ).as_ref()); + self.command_line.push_info( + crate::tf!("VPLAYER: {thawed_count} layer(s) thawed in viewport.") + .as_ref(), + ); } // Sync layer panel so VP freeze columns update immediately. let doc_layers = self.tabs[i].scene.document.layers.clone(); @@ -3771,7 +3912,8 @@ impl OpenCADStudio { self.tabs[i] .scene .zoom_to_window(p1.as_vec3(), p2.as_vec3()); - self.command_line.push_output(crate::t!("Zoom Window").as_ref()); + self.command_line + .push_output(crate::t!("Zoom Window").as_ref()); } CmdResult::Measurement(msg) => { self.tabs[i].active_cmd = None; @@ -3877,7 +4019,8 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); - self.command_line.push_output(crate::t!("ALIGN: applied.").as_ref()); + self.command_line + .push_output(crate::t!("ALIGN: applied.").as_ref()); self.refresh_properties(); if let Some(pd) = pending { self.commit_undo_delta(i, pd); @@ -3905,7 +4048,8 @@ impl OpenCADStudio { self.tabs[i].scene.erase_entities(&[handle]); self.tabs[i].scene.add_entity(new_entity); self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("LENGTHEN: applied.").as_ref()); + self.command_line + .push_output(crate::t!("LENGTHEN: applied.").as_ref()); self.refresh_properties(); } None => { @@ -3938,8 +4082,9 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("DIVIDE: {count} marker(s) placed.").as_ref()); } else { - self.command_line - .push_error(crate::t!("DIVIDE: entity type not supported or N < 2.").as_ref()); + self.command_line.push_error( + crate::t!("DIVIDE: entity type not supported or N < 2.").as_ref(), + ); } self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -3981,24 +4126,53 @@ impl OpenCADStudio { } CmdResult::PeditOp { handle, op } => return self.apply_pedit_result(i, handle, op), CmdResult::JoinToSource { source, handles } => { - if self.reject_locked_edit(i, source) { return Task::none(); } - let joined = self.tabs[i].scene.document.get_entity(source).and_then(|entity| { - let candidates: Vec<_> = handles.iter().filter(|handle| **handle != source && !self.tabs[i].scene.is_layer_locked(**handle)) - .filter_map(|handle| self.tabs[i].scene.document.get_entity(*handle).map(|entity| (*handle,entity))).collect(); - crate::modules::draw::modify::join::join_to_source(entity,&candidates) - }); + if self.reject_locked_edit(i, source) { + return Task::none(); + } + let joined = self.tabs[i] + .scene + .document + .get_entity(source) + .and_then(|entity| { + let candidates: Vec<_> = handles + .iter() + .filter(|handle| { + **handle != source && !self.tabs[i].scene.is_layer_locked(**handle) + }) + .filter_map(|handle| { + self.tabs[i] + .scene + .document + .get_entity(*handle) + .map(|entity| (*handle, entity)) + }) + .collect(); + crate::modules::draw::modify::join::join_to_source(entity, &candidates) + }); if let Some((replacement, consumed)) = joined { - self.push_undo_snapshot(i,"JOIN"); - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(source) { *entity = replacement; } + self.push_undo_snapshot(i, "JOIN"); + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(source) { + *entity = replacement; + } self.tabs[i].scene.erase_entities(&consumed); self.tabs[i].scene.refresh_fill_model(source); - self.tabs[i].scene.bump_entities(&[(source,crate::scene::ChangeKind::Modified)]); + self.tabs[i] + .scene + .bump_entities(&[(source, crate::scene::ChangeKind::Modified)]); self.tabs[i].dirty = true; - self.command_line.push_output(&format!("JOIN: {} objects joined to source.",consumed.len())); + self.command_line.push_output(&format!( + "JOIN: {} objects joined to source.", + consumed.len() + )); self.refresh_properties(); - } else { self.command_line.push_info("JOIN: no compatible objects joined to source."); } - self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); + } else { + self.command_line + .push_info("JOIN: no compatible objects joined to source."); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); } CmdResult::JoinEntities(handles) => { if let Some(handle) = handles @@ -4028,9 +4202,10 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); - self.command_line.push_output(crate::tf!( - "JOIN: {count_in} object(s) joined into {count_out}." - ).as_ref()); + self.command_line.push_output( + crate::tf!("JOIN: {count_in} object(s) joined into {count_out}.") + .as_ref(), + ); self.refresh_properties(); } None => { @@ -4056,12 +4231,14 @@ impl OpenCADStudio { .and_then(|e| break_entity(e, p1, p2)); match replacement { Some(frags) => { - let unchanged = frags.len() == 1 && self.tabs[i].scene.document.get_entity(handle) - .is_some_and(|original| { - let mut fragment = frags[0].clone(); - fragment.common_mut().handle = handle; - &fragment == original - }); + let unchanged = frags.len() == 1 + && self.tabs[i].scene.document.get_entity(handle).is_some_and( + |original| { + let mut fragment = frags[0].clone(); + fragment.common_mut().handle = handle; + &fragment == original + }, + ); if unchanged { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -4073,7 +4250,10 @@ impl OpenCADStudio { let label = self.history_label_from_active_cmd(i, "BREAK"); self.push_undo_snapshot(i, label); let count = frags.len(); - let owner = self.tabs[i].scene.document.get_entity(handle) + let owner = self.tabs[i] + .scene + .document + .get_entity(handle) .map(|entity| entity.common().owner_handle); let mut fragments = frags.into_iter(); if let Some(mut first) = fragments.next() { @@ -4081,7 +4261,9 @@ impl OpenCADStudio { self.tabs[i].scene.update_entity(first); for mut fragment in fragments { fragment.common_mut().handle = Handle::NULL; - if let Some(owner) = owner { fragment.common_mut().owner_handle = owner; } + if let Some(owner) = owner { + fragment.common_mut().owner_handle = owner; + } self.tabs[i].scene.add_entity(fragment); } } else { @@ -4115,8 +4297,9 @@ impl OpenCADStudio { let x1 = p1.x.max(p2.x); let y1 = p1.y.max(p2.y); self.plot_window = Some((x0, y0, x1, y1)); - self.command_line - .push_output(crate::tf!("Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}").as_ref()); + self.command_line.push_output( + crate::tf!("Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}").as_ref(), + ); // Pick window closed the plot dialog so the viewport could // receive the two clicks — bring the dialog back with the // window now active. @@ -4133,9 +4316,9 @@ impl OpenCADStudio { let x2 = p1.x.max(p2.x); let y2 = p1.y.max(p2.y); self.plot_window = Some((x1, y1, x2, y2)); - self.command_line.push_output(crate::tf!( - "Plot window: {x1:.2},{y1:.2} to {x2:.2},{y2:.2}" - ).as_ref()); + self.command_line.push_output( + crate::tf!("Plot window: {x1:.2},{y1:.2} to {x2:.2},{y2:.2}").as_ref(), + ); self.plot_dialog.area = "Window".to_string(); self.active_modal = Some(super::ModalKind::Plot); } @@ -4164,10 +4347,7 @@ impl OpenCADStudio { handles.extend( scene .interaction_handles_in_world_aabb([ - win_min.x, - win_min.y, - win_max.x, - win_max.y, + win_min.x, win_min.y, win_max.x, win_max.y, ]) .into_iter() .filter(|&handle| !scene.is_layer_locked(handle)), @@ -4179,9 +4359,8 @@ impl OpenCADStudio { handles.dedup(); if handles.is_empty() { - self.command_line.push_output( - crate::t!("STRETCH: nothing crosses the window.").as_ref(), - ); + self.command_line + .push_output(crate::t!("STRETCH: nothing crosses the window.").as_ref()); } else { // Visual feedback must only highlight entities that actually have a // stretchable point inside one of the crossing windows. `handles` can @@ -4264,11 +4443,7 @@ impl OpenCADStudio { let wires = self.tabs[i].scene.wire_models_for(&handles); - let cmd = StretchCommand::with_windows( - handles, - wires, - windows, - ); + let cmd = StretchCommand::with_windows(handles, wires, windows); self.command_line.push_info(&CadCommand::prompt(&cmd)); self.tabs[i].active_cmd = Some(Box::new(cmd)); @@ -4297,10 +4472,7 @@ impl OpenCADStudio { // Drawing plane is world XY (= DXF XY). let in_win = |x: f64, y: f64| -> bool { windows.iter().any(|(win_min, win_max)| { - x >= win_min.x - && x <= win_max.x - && y >= win_min.y - && y <= win_max.y + x >= win_min.x && x <= win_max.x && y >= win_min.y && y <= win_max.y }) }; @@ -4335,8 +4507,7 @@ impl OpenCADStudio { } } acadrust::EntityType::LwPolyline(p) => { - let Some(mut world) = - crate::entities::curve::lwpolyline_world_xy(p) + let Some(mut world) = crate::entities::curve::lwpolyline_world_xy(p) else { continue; }; @@ -4419,12 +4590,7 @@ impl OpenCADStudio { } acadrust::EntityType::Viewport(vp) => { stretched = windows.iter().any(|(win_min, win_max)| { - crate::entities::viewport::stretch( - vp, - *win_min, - *win_max, - delta, - ) + crate::entities::viewport::stretch(vp, *win_min, *win_max, delta) }); } acadrust::EntityType::Dimension(dim) => { @@ -4573,9 +4739,7 @@ impl OpenCADStudio { return Task::none(); } use crate::command::{ExtrudeExtent, ExtrudeMode}; - use crate::modules::insert::solid3d_cmds::{ - empty_extruded_surface, empty_solid3d, - }; + use crate::modules::insert::solid3d_cmds::{empty_extruded_surface, empty_solid3d}; use crate::scene::model::sweep_model; let path = match extent { ExtrudeExtent::Path(handle) => self.tabs[i] @@ -4597,7 +4761,8 @@ impl OpenCADStudio { let mut consumed = Vec::new(); let mut failed = 0usize; for handle in &handles { - let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { + let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() + else { failed += 1; continue; }; @@ -4627,20 +4792,16 @@ impl OpenCADStudio { (false, Some((_, path)), _) => { sweep_model::extruded_along_path(&entity, path, taper_angle) } - (false, None, Some(direction)) => { - sweep_model::extruded_direction( - &entity, - direction.to_array(), - taper_angle, - ) - } - (true, None, Some(direction)) => { - sweep_model::extruded_surface( - &entity, - direction.to_array(), - taper_angle, - ) - } + (false, None, Some(direction)) => sweep_model::extruded_direction( + &entity, + direction.to_array(), + taper_angle, + ), + (true, None, Some(direction)) => sweep_model::extruded_surface( + &entity, + direction.to_array(), + taper_angle, + ), (true, Some(_), _) => path_direction.and_then(|direction| { sweep_model::extruded_surface( &entity, @@ -4655,9 +4816,7 @@ impl OpenCADStudio { continue; }; let created = if creates_surface { - let direction = direction - .or(path_direction) - .unwrap_or(glam::DVec3::ZERO); + let direction = direction.or(path_direction).unwrap_or(glam::DVec3::ZERO); let history = path .is_none() .then(|| { @@ -4689,28 +4848,28 @@ impl OpenCADStudio { None => self.add_surface_model(surface, body), } } else { - let direction = direction.unwrap_or(glam::DVec3::ZERO); - let history = path - .as_ref() - .and_then(|(_, path)| { - sweep_model::sweep_history( - &entity, - path, - taper_angle, - profile.plane.origin, - ) - }) - .or_else(|| { - sweep_model::extrusion_history( - &entity, - None, - direction.to_array(), - taper_angle, - profile.plane.origin, - ) - }) + let direction = direction.unwrap_or(glam::DVec3::ZERO); + let history = path + .as_ref() + .and_then(|(_, path)| { + sweep_model::sweep_history( + &entity, + path, + taper_angle, + profile.plane.origin, + ) + }) + .or_else(|| { + sweep_model::extrusion_history( + &entity, + None, + direction.to_array(), + taper_angle, + profile.plane.origin, + ) + }) .unwrap_or_else(|| crate::scene::model::solid_history::brep_op(&body)); - self.add_solid_model(empty_solid3d(), body, history) + self.add_solid_model(empty_solid3d(), body, history) }; if created.is_null() { failed += 1; @@ -4768,9 +4927,8 @@ impl OpenCADStudio { return Task::none(); } if !distance.is_finite() { - self.command_line.push_error( - crate::t!("Requires numeric distance or two points.").as_ref(), - ); + self.command_line + .push_error(crate::t!("Requires numeric distance or two points.").as_ref()); return Task::none(); } if distance.abs() <= f64::EPSILON { @@ -4789,13 +4947,12 @@ impl OpenCADStudio { failed += 1; continue; }; - let kernel_distance = if surface.kind - == acadrust::entities::SurfaceKind::Revolved - { - -distance - } else { - distance - }; + let kernel_distance = + if surface.kind == acadrust::entities::SurfaceKind::Revolved { + -distance + } else { + distance + }; let body = self.tabs[i] .scene .solid_models @@ -4852,10 +5009,19 @@ impl OpenCADStudio { self.refresh_properties(); } - CmdResult::PresspullPick { handle, point, offset, multiple: _ } => { + CmdResult::PresspullPick { + handle, + point, + offset, + multiple: _, + } => { self.presspull_pick(handle, point, offset); } - CmdResult::PresspullApply { targets, distance, color: _ } => { + CmdResult::PresspullApply { + targets, + distance, + color: _, + } => { self.presspull_apply(targets, distance); } @@ -4878,9 +5044,7 @@ impl OpenCADStudio { } let delete_sources = self.delete_objects != 0; use crate::command::ExtrudeMode; - use crate::modules::insert::solid3d_cmds::{ - empty_revolved_surface, empty_solid3d, - }; + use crate::modules::insert::solid3d_cmds::{empty_revolved_surface, empty_solid3d}; use crate::scene::model::sweep_model; let from = axis_start.to_array(); let to = axis_end.to_array(); @@ -4929,27 +5093,21 @@ impl OpenCADStudio { body, ) } else { - let result = sweep_model::revolve_history( - &entity, - from, - to, - angle, - start_angle, - ) - .and_then(|history| { - cadkernel::acis::rebuild_body(&history) - .ok() - .map(|solid| (solid, history)) - }) - .or_else(|| { - sweep_model::revolved(&entity, from, to, angle, start_angle).map( - |solid| { - let history = - crate::scene::model::solid_history::brep_op(&solid); - (solid, history) - }, - ) - }); + let result = + sweep_model::revolve_history(&entity, from, to, angle, start_angle) + .and_then(|history| { + cadkernel::acis::rebuild_body(&history) + .ok() + .map(|solid| (solid, history)) + }) + .or_else(|| { + sweep_model::revolved(&entity, from, to, angle, start_angle) + .map(|solid| { + let history = + crate::scene::model::solid_history::brep_op(&solid); + (solid, history) + }) + }); let Some((solid, history)) = result else { failed += 1; continue; @@ -5026,7 +5184,10 @@ impl OpenCADStudio { failed += 1; } } - let selection = profiles.iter().map(|(_, entity)| entity.clone()).collect::>(); + let selection = profiles + .iter() + .map(|(_, entity)| entity.clone()) + .collect::>(); let options = sweep_model::sweep_selection_options(&selection, options); let pending = if profiles.is_empty() { None @@ -5039,20 +5200,21 @@ impl OpenCADStudio { let result = path.as_ref().zip(options).and_then(|(path, options)| { let record = sweep_model::sweep_record(&profile, path, options)?; let (_, _, closed) = cadkernel::acis::sweep_profile_geometry( - record.sweep_entity.as_ref()?, record.sweep_entity_transform, - ).ok()?; + record.sweep_entity.as_ref()?, + record.sweep_entity_transform, + ) + .ok()?; let surface = mode == ExtrudeMode::Surface || !closed; - let body = cadkernel::acis::rebuild_sweep_with_mode(&record, surface).ok()?; + let body = + cadkernel::acis::rebuild_sweep_with_mode(&record, surface).ok()?; Some((body, record, surface)) }); let Some((body, record, surface)) = result else { failed += 1; continue; }; - let deletes_path = crate::app::delobj_deletes_auxiliary( - delete_objects, - surface, - ); + let deletes_path = + crate::app::delobj_deletes_auxiliary(delete_objects, surface); if deletes_path && self.tabs[i].scene.is_layer_locked(path_handle) { failed += 1; continue; @@ -5061,7 +5223,8 @@ impl OpenCADStudio { self.add_surface_model(sweep_model::swept_surface_entity(&record), body) } else { self.add_solid_model( - empty_solid3d(), body, + empty_solid3d(), + body, acadrust::objects::SolidHistoryOperation::Sweep(record), ) }; @@ -5091,7 +5254,9 @@ impl OpenCADStudio { created = created_handles.len(), failed = failed ).as_ref()); } else { - self.command_line.push_error(crate::t!("SWEEP: could not sweep the profile along the path.").as_ref()); + self.command_line.push_error( + crate::t!("SWEEP: could not sweep the profile along the path.").as_ref(), + ); } if let Some(pending) = pending { self.commit_undo_delta(i, pending); @@ -5104,39 +5269,69 @@ impl OpenCADStudio { } // ── LOFT ────────────────────────────────────────────────────── - CmdResult::LoftEntities { sections, guides, path, mode, options, color: _ } => { + CmdResult::LoftEntities { + sections, + guides, + path, + mode, + options, + color: _, + } => { use crate::command::LoftSectionSelection; use crate::modules::insert::solid3d_cmds::empty_solid3d; use crate::scene::model::loft_command_model; - let mut sources = sections.iter().flat_map(|section| match section { - LoftSectionSelection::Entity(handle) => vec![*handle], - LoftSectionSelection::Join(handles) => handles.clone(), - LoftSectionSelection::Point(_) => Vec::new(), - }).collect::>(); + let mut sources = sections + .iter() + .flat_map(|section| match section { + LoftSectionSelection::Entity(handle) => vec![*handle], + LoftSectionSelection::Join(handles) => handles.clone(), + LoftSectionSelection::Point(_) => Vec::new(), + }) + .collect::>(); sources.sort_unstable_by_key(|handle| handle.value()); sources.dedup(); let delete_objects = self.delete_objects; let delete_sections = delete_objects != 0; let section_locked = delete_sections - && sources.iter().any(|handle| self.tabs[i].scene.is_layer_locked(*handle)); - let available = sources.iter().chain(guides.iter()).copied().chain(path) - .filter_map(|handle| self.tabs[i].scene.document.get_entity(handle) - .cloned().map(|entity| (handle, entity))).collect::>(); + && sources + .iter() + .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)); + let available = sources + .iter() + .chain(guides.iter()) + .copied() + .chain(path) + .filter_map(|handle| { + self.tabs[i] + .scene + .document + .get_entity(handle) + .cloned() + .map(|entity| (handle, entity)) + }) + .collect::>(); let result = if section_locked { Err("LOFT: a source is on a locked layer; disable source deletion or unlock it.".to_string()) } else { loft_command_model::record(§ions, &guides, path, &available, mode, options) - .and_then(|record| cadkernel::acis::rebuild_loft_with_options(&record).map(|body| (body, record))) + .and_then(|record| { + cadkernel::acis::rebuild_loft_with_options(&record) + .map(|body| (body, record)) + }) }; match result { Ok((body, record)) => { - let surface = record.parameters.as_ref().is_some_and(|settings| settings.surface); - let delete_auxiliary = crate::app::delobj_deletes_auxiliary( - delete_objects, - surface, - ); + let surface = record + .parameters + .as_ref() + .is_some_and(|settings| settings.surface); + let delete_auxiliary = + crate::app::delobj_deletes_auxiliary(delete_objects, surface); if delete_auxiliary - && guides.iter().copied().chain(path) + && guides + .iter() + .copied() + .chain(path) .any(|handle| self.tabs[i].scene.is_layer_locked(handle)) { self.command_line.push_error( @@ -5147,15 +5342,23 @@ impl OpenCADStudio { let dirty_before = self.tabs[i].dirty; let pending = self.begin_undo(i, "LOFT", 1, true); let created = if surface { - let handle = self.add_surface_model(loft_command_model::surface_entity(&record), body); + let handle = self.add_surface_model( + loft_command_model::surface_entity(&record), + body, + ); if !handle.is_null() { - self.tabs[i].scene.create_solid_history(handle, - acadrust::objects::SolidHistoryOperation::Loft(record)); + self.tabs[i].scene.create_solid_history( + handle, + acadrust::objects::SolidHistoryOperation::Loft(record), + ); } handle } else { - self.add_solid_model(empty_solid3d(), body, - acadrust::objects::SolidHistoryOperation::Loft(record)) + self.add_solid_model( + empty_solid3d(), + body, + acadrust::objects::SolidHistoryOperation::Loft(record), + ) }; if created.is_null() { // The creation helper already removed its provisional @@ -5182,10 +5385,14 @@ impl OpenCADStudio { self.tabs[i].dirty = true; let message = if surface { crate::t!("LOFT: surface created.") - } else { crate::t!("LOFT: solid created.") }; + } else { + crate::t!("LOFT: solid created.") + }; self.command_line.push_output(message.as_ref()); } - if let Some(pending) = pending { self.commit_undo_delta(i, pending); } + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } } Err(error) => { self.command_line.push_error(&error); @@ -5207,14 +5414,8 @@ impl OpenCADStudio { other_value, fillet, } => { - let task = self.solid_edge_blend( - handle, - &edges, - base_face, - value, - other_value, - fillet, - ); + let task = + self.solid_edge_blend(handle, &edges, base_face, value, other_value, fillet); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); @@ -5292,39 +5493,63 @@ impl OpenCADStudio { .push_error(crate::t!("HATCHEDIT: hatch entity not found.").as_ref()); } else { use crate::command::{CadCommand, HatchEditOperation}; - if matches!(&operation,HatchEditOperation::BeginAssociate) { - let associative=matches!(self.tabs[i].scene.document.get_entity(handle),Some(acadrust::EntityType::Hatch(h)) if h.is_associative); + if matches!(&operation, HatchEditOperation::BeginAssociate) { + let associative = matches!(self.tabs[i].scene.document.get_entity(handle),Some(acadrust::EntityType::Hatch(h)) if h.is_associative); if associative { - self.command_line.push_info("HATCHEDIT: hatch is already associative."); - self.tabs[i].active_cmd=None; - }else{ - let plane=match self.tabs[i].scene.document.get_entity(handle) { - Some(acadrust::EntityType::Hatch(h))=>{ - let storage=crate::entities::curve::ocs_plane(h.normal,h.elevation); - crate::command::WorkingPlane::new(glam::DVec3::from_array(storage.origin),glam::DVec3::from_array(storage.x_axis),glam::DVec3::from_array(storage.y_axis)) - }, - _=>self.tabs[i].ucs_xform().working_plane(), + self.command_line + .push_info("HATCHEDIT: hatch is already associative."); + self.tabs[i].active_cmd = None; + } else { + let plane = match self.tabs[i].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Hatch(h)) => { + let storage = + crate::entities::curve::ocs_plane(h.normal, h.elevation); + crate::command::WorkingPlane::new( + glam::DVec3::from_array(storage.origin), + glam::DVec3::from_array(storage.x_axis), + glam::DVec3::from_array(storage.y_axis), + ) + } + _ => self.tabs[i].ucs_xform().working_plane(), }; - let mut sources=self.tabs[i].scene.boundary_sources_on_plane(plane,1e-6); + let mut sources = + self.tabs[i].scene.boundary_sources_on_plane(plane, 1e-6); sources.remove(&handle); let command=crate::modules::draw::draw::hatchedit::HatcheditCommand::for_association(handle,name,scale,angle,plane,sources); self.tabs[i].scene.deselect_all(); self.command_line.push_info(&command.prompt()); - self.tabs[i].active_cmd=Some(Box::new(command)); + self.tabs[i].active_cmd = Some(Box::new(command)); } return Task::none(); } - if let HatchEditOperation::DrawOrderBoundary{above}=&operation { - let references:Vec<_>=match self.tabs[i].scene.document.get_entity(handle) { - Some(acadrust::EntityType::Hatch(h))=>h.paths.iter().flat_map(|p|p.boundary_handles.iter()) - .filter(|h|self.tabs[i].scene.document.get_entity(**h).is_some()).map(|h|format!("{:X}",h.value())).collect(), - _=>Vec::new(), - }; - self.tabs[i].active_cmd=None; - if references.is_empty(){self.command_line.push_info("HATCHEDIT: no associated boundary objects.");return Task::none();} - self.tabs[i].scene.deselect_all();self.tabs[i].scene.select_entity(handle,false); - let command=format!("DRAWORDER {} {}",if *above{"ABOVE"}else{"UNDER"},references.join(" ")); - return self.dispatch_view(&command,i).unwrap_or_else(Task::none); + if let HatchEditOperation::DrawOrderBoundary { above } = &operation { + let references: Vec<_> = + match self.tabs[i].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Hatch(h)) => h + .paths + .iter() + .flat_map(|p| p.boundary_handles.iter()) + .filter(|h| { + self.tabs[i].scene.document.get_entity(**h).is_some() + }) + .map(|h| format!("{:X}", h.value())) + .collect(), + _ => Vec::new(), + }; + self.tabs[i].active_cmd = None; + if references.is_empty() { + self.command_line + .push_info("HATCHEDIT: no associated boundary objects."); + return Task::none(); + } + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(handle, false); + let command = format!( + "DRAWORDER {} {}", + if *above { "ABOVE" } else { "UNDER" }, + references.join(" ") + ); + return self.dispatch_view(&command, i).unwrap_or_else(Task::none); } if matches!( &operation, @@ -5342,20 +5567,43 @@ impl OpenCADStudio { } self.push_undo_snapshot(i, "HATCHEDIT"); match operation { - HatchEditOperation::Appearance { color, layer, transparency } => { - let layer=layer.map(|name|if name=="."{self.tabs[i].active_layer.clone()}else{name}); - if layer.as_ref().is_some_and(|name|self.tabs[i].scene.document.layers.get(name).is_none()) { + HatchEditOperation::Appearance { + color, + layer, + transparency, + } => { + let layer = layer.map(|name| { + if name == "." { + self.tabs[i].active_layer.clone() + } else { + name + } + }); + if layer.as_ref().is_some_and(|name| { + self.tabs[i].scene.document.layers.get(name).is_none() + }) { self.discard_last_undo_entry(i); self.command_line.push_error("HATCHEDIT: layer not found."); return Task::none(); } - if let Some(entity)=self.tabs[i].scene.document.get_entity_mut(handle) { - let common=entity.common_mut(); - if let Some(value)=color {common.color=value;common.color_name=None;common.color_book_handle=None;} - if let Some(value)=layer {common.layer=value;} - if let Some(value)=transparency {common.transparency=value;} + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) + { + let common = entity.common_mut(); + if let Some(value) = color { + common.color = value; + common.color_name = None; + common.color_book_handle = None; + } + if let Some(value) = layer { + common.layer = value; + } + if let Some(value) = transparency { + common.transparency = value; + } } - self.tabs[i].scene.bump_entities(&[(handle,crate::scene::ChangeKind::Modified)]); + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); self.refresh_properties(); } HatchEditOperation::Update { @@ -5369,7 +5617,8 @@ impl OpenCADStudio { if let Some((x, y)) = origin { if !self.tabs[i].scene.document.set_hatch_origin([x, y]) { self.discard_last_undo_entry(i); - self.command_line.push_error("HATCHEDIT: cannot store default origin."); + self.command_line + .push_error("HATCHEDIT: cannot store default origin."); return Task::none(); } } @@ -5381,7 +5630,10 @@ impl OpenCADStudio { if let Some(entry) = crate::scene::model::hatch_patterns::find(&name) { - let mut pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry); + let mut pattern = + crate::scene::model::hatch_patterns::build_dxf_pattern( + entry, + ); crate::entities::hatch::scale_pattern_geometry( &mut pattern, scale.max(1.0e-6) as f64, @@ -5391,7 +5643,11 @@ impl OpenCADStudio { (angle as f64).to_radians(), ); let origin = hatch.pattern_origin(); - crate::entities::hatch::translate_pattern_geometry(&mut pattern, origin.x, origin.y); + crate::entities::hatch::translate_pattern_geometry( + &mut pattern, + origin.x, + origin.y, + ); hatch.pattern = pattern; hatch.is_solid = matches!( entry.gpu, @@ -5445,10 +5701,9 @@ impl OpenCADStudio { } } } - self.tabs[i].scene.bump_entities(&[( - handle, - crate::scene::ChangeKind::Modified, - )]); + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); } HatchEditOperation::AddBoundaries(handles) => { self.tabs[i] @@ -5461,12 +5716,25 @@ impl OpenCADStudio { .edit_hatch_boundary_handles(handle, &handles, false); } HatchEditOperation::AssociatePaths(paths) => { - if paths.is_empty()||paths.iter().any(|path|path.boundary_handles.is_empty()||path.boundary_handles.iter().any(|source|self.tabs[i].scene.document.get_entity(*source).is_none())) { + if paths.is_empty() + || paths.iter().any(|path| { + path.boundary_handles.is_empty() + || path.boundary_handles.iter().any(|source| { + self.tabs[i] + .scene + .document + .get_entity(*source) + .is_none() + }) + }) + { self.discard_last_undo_entry(i); - self.command_line.push_error("HATCHEDIT: associated boundary is no longer available."); + self.command_line.push_error( + "HATCHEDIT: associated boundary is no longer available.", + ); return Task::none(); } - self.tabs[i].scene.replace_hatch_association(handle,paths); + self.tabs[i].scene.replace_hatch_association(handle, paths); } HatchEditOperation::RecreateBoundary { associate, region } => { let source = self.tabs[i].scene.document.get_entity(handle).cloned(); @@ -5481,33 +5749,79 @@ impl OpenCADStudio { glam::DVec3::from_array(storage.y_axis), ); let (entities, path_groups) = if region { - let loops = source.paths.iter().map(|path| { - path.edges.iter().map(crate::entities::hatch::edge_curve).collect::>>() - }).collect::>>(); - let Some(loops) = loops.filter(|loops| !loops.is_empty() && loops.iter().all(|ring| !ring.is_empty())) else { + let loops = source + .paths + .iter() + .map(|path| { + path.edges + .iter() + .map(crate::entities::hatch::edge_curve) + .collect::>>() + }) + .collect::>>(); + let Some(loops) = loops.filter(|loops| { + !loops.is_empty() + && loops.iter().all(|ring| !ring.is_empty()) + }) else { self.discard_last_undo_entry(i); - self.command_line.push_error("HATCHEDIT: boundary cannot form a region."); + self.command_line.push_error( + "HATCHEDIT: boundary cannot form a region.", + ); return Task::none(); }; - let contained = loops.iter().enumerate().map(|(inner, ring)| { - let seed = ring[0].point_at(0.0); - loops.iter().enumerate().map(|(outer, boundary)| { - inner != outer && cadkernel::geom2d::contains(boundary, seed, cadkernel::geom2d::Tolerance::new(1e-6)) - }).collect::>() - }).collect::>(); - let depths = contained.iter().map(|row| row.iter().filter(|inside| **inside).count()).collect::>(); - let groups = depths.iter().enumerate().filter(|(_, depth)| **depth % 2 == 0).map(|(outer, depth)| { - let mut indices = vec![outer]; - indices.extend((0..loops.len()).filter(|inner| depths[*inner] == *depth + 1 && contained[*inner][outer])); - indices - }).collect::>(); - let entities = groups.iter().map(|indices| { - let profiles = indices.iter().map(|index| loops[*index].clone()).collect::>(); - crate::scene::model::presspull_model::region_from_loops(&profiles, plane) - }).collect::>>(); + let contained = loops + .iter() + .enumerate() + .map(|(inner, ring)| { + let seed = ring[0].point_at(0.0); + loops + .iter() + .enumerate() + .map(|(outer, boundary)| { + inner != outer + && cadkernel::geom2d::contains( + boundary, + seed, + cadkernel::geom2d::Tolerance::new(1e-6), + ) + }) + .collect::>() + }) + .collect::>(); + let depths = contained + .iter() + .map(|row| row.iter().filter(|inside| **inside).count()) + .collect::>(); + let groups = depths + .iter() + .enumerate() + .filter(|(_, depth)| **depth % 2 == 0) + .map(|(outer, depth)| { + let mut indices = vec![outer]; + indices.extend((0..loops.len()).filter(|inner| { + depths[*inner] == *depth + 1 + && contained[*inner][outer] + })); + indices + }) + .collect::>(); + let entities = groups + .iter() + .map(|indices| { + let profiles = indices + .iter() + .map(|index| loops[*index].clone()) + .collect::>(); + crate::scene::model::presspull_model::region_from_loops( + &profiles, plane, + ) + }) + .collect::>>(); let Some(entities) = entities else { self.discard_last_undo_entry(i); - self.command_line.push_error("HATCHEDIT: boundary cannot form a region."); + self.command_line.push_error( + "HATCHEDIT: boundary cannot form a region.", + ); return Task::none(); }; (entities, groups) @@ -5528,30 +5842,39 @@ impl OpenCADStudio { ); return Task::none(); } - let groups = (0..entities.len()).map(|index| vec![index]).collect(); + let groups = + (0..entities.len()).map(|index| vec![index]).collect(); (entities, groups) }; let mut path_handles = vec![None; source.paths.len()]; for (entity, paths) in entities.into_iter().zip(path_groups) { if let Some(boundary) = self.commit_entity_handle(entity) { - for path in paths { if let Some(slot) = path_handles.get_mut(path) { *slot = Some(boundary); } } - } - } - if associate { if let Some(acadrust::EntityType::Hatch(hatch)) = - self.tabs[i].scene.document.get_entity_mut(handle) - { - for (path, boundary) in hatch.paths.iter_mut().zip(path_handles.iter().copied()) { - if let Some(boundary) = boundary { - path.boundary_handles = vec![boundary]; - path.flags.set_external(true); + for path in paths { + if let Some(slot) = path_handles.get_mut(path) { + *slot = Some(boundary); + } } } - hatch.is_associative = path_handles.iter().all(Option::is_some); - } } - self.tabs[i].scene.bump_entities(&[( - handle, - crate::scene::ChangeKind::Modified, - )]); + } + if associate { + if let Some(acadrust::EntityType::Hatch(hatch)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + for (path, boundary) in + hatch.paths.iter_mut().zip(path_handles.iter().copied()) + { + if let Some(boundary) = boundary { + path.boundary_handles = vec![boundary]; + path.flags.set_external(true); + } + } + hatch.is_associative = + path_handles.iter().all(Option::is_some); + } + } + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); } } HatchEditOperation::Separate => { @@ -5577,7 +5900,7 @@ impl OpenCADStudio { } HatchEditOperation::DrawOrderFront | HatchEditOperation::DrawOrderBack - | HatchEditOperation::DrawOrderBoundary {..} + | HatchEditOperation::DrawOrderBoundary { .. } | HatchEditOperation::BeginAssociate => unreachable!(), } self.tabs[i].dirty = true; @@ -5677,10 +6000,17 @@ impl OpenCADStudio { let active = self.tabs[i].active_cmd.take(); self.undo_active_tab(); self.tabs[i].active_cmd = active; - if self.tabs[i].active_cmd.as_ref().is_some_and(|command| command.name() == "PEDIT") { - let entities: Vec<_> = self.tabs[i].scene.document.entities().cloned().collect(); + if self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.name() == "PEDIT") + { + let entities: Vec<_> = + self.tabs[i].scene.document.entities().cloned().collect(); if let Some(command) = self.tabs[i].active_cmd.as_mut() { - for entity in entities { command.inject_picked_entity(entity); } + for entity in entities { + command.inject_picked_entity(entity); + } } } let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); @@ -5709,8 +6039,9 @@ impl OpenCADStudio { ) }); if handles.is_empty() { - self.command_line - .push_error(crate::t!("DIMEDIT: no editable dimensions were selected.").as_ref()); + self.command_line.push_error( + crate::t!("DIMEDIT: no editable dimensions were selected.").as_ref(), + ); } else { self.push_undo_snapshot(i, "DIMEDIT"); for handle in &handles { @@ -5806,7 +6137,8 @@ impl OpenCADStudio { } if updated { self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("DDEDIT: text updated.").as_ref()); + self.command_line + .push_output(crate::t!("DDEDIT: text updated.").as_ref()); } else { self.discard_last_undo_entry(i); self.command_line @@ -5851,11 +6183,7 @@ impl OpenCADStudio { /// this drawing doesn't already have. Each recreated record gets a fresh /// handle from the target document so it can't collide with an existing /// one. No-op for same-document pastes (the records already exist). (#129) - pub(super) fn merge_dependencies( - &mut self, - i: usize, - deps: &crate::app::ClipboardDeps, - ) { + pub(super) fn merge_dependencies(&mut self, i: usize, deps: &crate::app::ClipboardDeps) { use acadrust::TableEntry; if deps.is_empty() { return; @@ -5980,7 +6308,7 @@ impl OpenCADStudio { _ => glam::DVec3::ZERO, }; self.merge_clipboard_ext_objects(i, &by_index, annotation_delta); - // Source handles stored in the clipboard map one-to-one to the freshly + // Source handles stored in the clipboard map one-to-one to the freshly // pasted handles. Use that map to reconnect LEADER -> copied annotation. let mut handle_map = rustc_hash::FxHashMap::default(); @@ -6030,7 +6358,9 @@ impl OpenCADStudio { self.tabs[i].scene.recreate_groups(groups, &handle_map); } // Constraints wholly within the pasted selection follow it. - self.tabs[i].scene.duplicate_sketch_constraints_for(&handle_map); + self.tabs[i] + .scene + .duplicate_parametric_constraints_for(&handle_map); // Incremental: `add_entity` already tessellated every pasted top-level // solid, and existing document solids are still cached — so only newly // introduced block-definition solids need building. The full rebuild @@ -6330,16 +6660,10 @@ fn wire_segments( for model in models { for run in model.points.split(|point| !point[0].is_finite()) { for pair in run.windows(2) { - let first = glam::DVec3::new( - pair[0][0] as f64, - pair[0][1] as f64, - pair[0][2] as f64, - ); - let second = glam::DVec3::new( - pair[1][0] as f64, - pair[1][1] as f64, - pair[1][2] as f64, - ); + let first = + glam::DVec3::new(pair[0][0] as f64, pair[0][1] as f64, pair[0][2] as f64); + let second = + glam::DVec3::new(pair[1][0] as f64, pair[1][1] as f64, pair[1][2] as f64); if first.is_finite() && second.is_finite() && first.distance_squared(second) > 1.0e-18 @@ -6361,7 +6685,10 @@ fn segment_intersection_xy( [second.0.to_array(), second.1.to_array()], 0.25, )?; - Some((glam::DVec3::from_array(gap[0]), glam::DVec3::from_array(gap[1]))) + Some(( + glam::DVec3::from_array(gap[0]), + glam::DVec3::from_array(gap[1]), + )) } fn break_object_handle(document: &acadrust::CadDocument, dimension: Handle) -> Option { @@ -6383,7 +6710,10 @@ fn remove_dimension_break_data(document: &mut acadrust::CadDocument, dimension: .filter_map(|(handle, object)| match object { acadrust::objects::ObjectType::DataObject(object) => match &object.data { acadrust::objects::DataObjectData::BreakData(data) - if data.dimension_reference == dimension => Some(*handle), + if data.dimension_reference == dimension => + { + Some(*handle) + } _ => None, }, _ => None, @@ -6463,9 +6793,7 @@ fn write_dimension_break_data( document .objects .insert(object_handle, ObjectType::DataObject(object)); - if let Some(ObjectType::Dictionary(dictionary)) = - document.objects.get_mut(&dictionary_handle) - { + if let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary_handle) { dictionary .entries .retain(|(name, _)| !name.eq_ignore_ascii_case("ACAD_BREAKDATA")); @@ -6523,8 +6851,11 @@ fn apply_dimbreak( .filter(|handle| remove_dimension_break_data(&mut scene.document, *handle)) .collect(); return DimBreakResult { - message: crate::tf!("DIMBREAK: removed breaks from {} dimension(s).", changed.len()) - .into_owned(), + message: crate::tf!( + "DIMBREAK: removed breaks from {} dimension(s).", + changed.len() + ) + .into_owned(), changed, }; } @@ -6592,11 +6923,14 @@ fn apply_dimbreak( } } let start = references.len() as i32; - references.extend(intersections.into_iter().enumerate().map( - |(index, points)| { - break_reference(start + index as i32, 1, points.0, points.1) - }, - )); + references.extend( + intersections + .into_iter() + .enumerate() + .map(|(index, points)| { + break_reference(start + index as i32, 1, points.0, points.1) + }), + ); } DimensionBreakOperation::Remove => {} } @@ -6633,16 +6967,28 @@ fn apply_dimspace( use acadrust::entities::Dimension; let (axis, normal, definition, auto_spacing) = match scene.document.get_entity(base_h) { Some(acadrust::EntityType::Dimension(dimension @ Dimension::Linear(d))) => { - let spacing = dimension_auto_spacing(&scene.document, dimension, scene.creation_annotation_multiplier()); + let spacing = dimension_auto_spacing( + &scene.document, + dimension, + scene.creation_annotation_multiplier(), + ); ( [d.rotation.cos(), d.rotation.sin(), 0.0], [d.base.normal.x, d.base.normal.y, d.base.normal.z], - [d.definition_point.x, d.definition_point.y, d.definition_point.z], + [ + d.definition_point.x, + d.definition_point.y, + d.definition_point.z, + ], spacing, ) } Some(acadrust::EntityType::Dimension(dimension @ Dimension::Aligned(d))) => { - let spacing = dimension_auto_spacing(&scene.document, dimension, scene.creation_annotation_multiplier()); + let spacing = dimension_auto_spacing( + &scene.document, + dimension, + scene.creation_annotation_multiplier(), + ); ( [ d.second_point.x - d.first_point.x, @@ -6650,7 +6996,11 @@ fn apply_dimspace( d.second_point.z - d.first_point.z, ], [d.base.normal.x, d.base.normal.y, d.base.normal.z], - [d.definition_point.x, d.definition_point.y, d.definition_point.z], + [ + d.definition_point.x, + d.definition_point.y, + d.definition_point.z, + ], spacing, ) } @@ -6667,11 +7017,8 @@ fn apply_dimspace( let mut changed = false; if let Some(acadrust::EntityType::Dimension(dim)) = scene.document.get_entity_mut(h) { let slide = |p: &mut acadrust::types::Vector3| { - let point = cadkernel::space::move_to_dimension_spacing( - [p.x, p.y, p.z], - frame, - target, - ); + let point = + cadkernel::space::move_to_dimension_spacing([p.x, p.y, p.z], frame, target); *p = acadrust::types::Vector3::new(point[0], point[1], point[2]); }; match dim { @@ -6709,8 +7056,14 @@ fn dimension_auto_spacing( let style = document .dim_styles .iter() - .find(|style| style.name.eq_ignore_ascii_case(&dimension.base().style_name)) - .map(|style| crate::entities::dimension::resolved_dimension_style(style, dimension, document)); + .find(|style| { + style + .name + .eq_ignore_ascii_case(&dimension.base().style_name) + }) + .map(|style| { + crate::entities::dimension::resolved_dimension_style(style, dimension, document) + }); style .map(|style| { let scale = if style.dimscale > 1.0e-9 { @@ -6740,9 +7093,7 @@ fn apply_mleader_align( }); let mut changed = Vec::new(); for &handle in handles { - if let Some(acadrust::EntityType::MultiLeader(ml)) = - scene.document.get_entity_mut(handle) - { + if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(handle) { let old = ml.context.content_base_point; let projected = closest_point(&line, [old.x, old.y]).point; let new_x = projected[0]; @@ -6779,9 +7130,7 @@ fn compatible_mleader_collect_handles( if scene.is_layer_locked(*handle) { return None; } - let acadrust::EntityType::MultiLeader(leader) = - scene.document.get_entity(*handle)? - else { + let acadrust::EntityType::MultiLeader(leader) = scene.document.get_entity(*handle)? else { return None; }; (leader.content_type == acadrust::entities::LeaderContentType::Block) @@ -6819,8 +7168,7 @@ fn apply_mleader_collect( } let px = point.x; let py = point.y; - let Some(acadrust::EntityType::MultiLeader(base)) = - scene.document.get_entity(handles[0]) + let Some(acadrust::EntityType::MultiLeader(base)) = scene.document.get_entity(handles[0]) else { return false; }; @@ -7309,21 +7657,40 @@ mod command_replacement_tests { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let tab = app.active_tab; - let handle = app.tabs[tab].scene.add_entity(acadrust::EntityType::Line( - Line::from_points(Vector3::ZERO, Vector3::new(1.0, 0.0, 0.0)), - )); - let owner = app.tabs[tab].scene.document.get_entity(handle).unwrap().common().owner_handle; + let handle = app.tabs[tab] + .scene + .add_entity(acadrust::EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(1.0, 0.0, 0.0), + ))); + let owner = app.tabs[tab] + .scene + .document + .get_entity(handle) + .unwrap() + .common() + .owner_handle; - let kept = app.replace_command_entity(tab, handle, vec![acadrust::EntityType::Line( - Line::from_points(Vector3::ZERO, Vector3::new(3.0, 0.0, 0.0)), - )]); + let kept = app.replace_command_entity( + tab, + handle, + vec![acadrust::EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(3.0, 0.0, 0.0), + ))], + ); assert_eq!(kept, vec![handle]); let edited = app.tabs[tab].scene.document.get_entity(handle).unwrap(); assert_eq!(edited.common().owner_handle, owner); assert!(matches!(edited, acadrust::EntityType::Line(line) if line.end.x == 3.0)); - let allocated = app.replace_command_entity(tab, handle, - vec![acadrust::EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 2.0))]); + let allocated = app.replace_command_entity( + tab, + handle, + vec![acadrust::EntityType::Circle(Circle::from_coords( + 0.0, 0.0, 0.0, 2.0, + ))], + ); assert_eq!(allocated.len(), 1); assert_ne!(allocated[0], handle); assert!(app.tabs[tab].scene.document.get_entity(handle).is_none()); @@ -7331,22 +7698,26 @@ mod command_replacement_tests { } #[cfg(test)] -mod sketch_constraint_undo_tests { +mod parametric_constraint_undo_tests { use super::*; - use crate::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; fn add_line(app: &mut OpenCADStudio, x1: f64, y1: f64, x2: f64, y2: f64) -> Handle { app.tabs[app.active_tab] .scene - .add_entity(acadrust::EntityType::Line(acadrust::entities::Line::from_points( - acadrust::types::Vector3::new(x1, y1, 0.0), - acadrust::types::Vector3::new(x2, y2, 0.0), - ))) + .add_entity(acadrust::EntityType::Line( + acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(x1, y1, 0.0), + acadrust::types::Vector3::new(x2, y2, 0.0), + ), + )) } fn line_angle_deg(app: &OpenCADStudio, handle: Handle) -> f64 { match app.tabs[app.active_tab].scene.document.get_entity(handle) { - Some(acadrust::EntityType::Line(l)) => (l.end.y - l.start.y).atan2(l.end.x - l.start.x).to_degrees(), + Some(acadrust::EntityType::Line(l)) => (l.end.y - l.start.y) + .atan2(l.end.x - l.start.x) + .to_degrees(), other => panic!("expected a Line, got {other:?}"), } } @@ -7360,15 +7731,23 @@ mod sketch_constraint_undo_tests { let original_angle = line_angle_deg(&app, line); assert!((original_angle - 16.699244).abs() < 1e-3); - let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Horizontal, - refs: vec![SketchRef::whole(line)], + refs: vec![ParametricRef::whole(line)], driving_param: None, label: "Horizontal constraint", }); - assert!(line_angle_deg(&app, line).abs() < 1e-6, "constraint should have leveled the line"); + assert!( + line_angle_deg(&app, line).abs() < 1e-6, + "constraint should have leveled the line" + ); assert_eq!( - app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(), + app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints + .len(), 1 ); @@ -7376,7 +7755,7 @@ mod sketch_constraint_undo_tests { assert_eq!( app.tabs[app.active_tab] .scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .map(|s| s.constraints.len()) .unwrap_or(0), 0, @@ -7388,15 +7767,23 @@ mod sketch_constraint_undo_tests { ); app.redo_steps(1); - assert!(line_angle_deg(&app, line).abs() < 1e-6, "redo should re-level the line"); + assert!( + line_angle_deg(&app, line).abs() < 1e-6, + "redo should re-level the line" + ); assert_eq!( - app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(), + app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints + .len(), 1, "redo should restore the constraint record" ); } - /// Drawing commands must not create sketch constraints implicitly. + /// Drawing commands must not create parametric constraints implicitly. #[test] fn drawing_a_line_onto_an_existing_endpoint_does_not_add_a_constraint() { let mut app = OpenCADStudio::new_for_test(); @@ -7407,13 +7794,15 @@ mod sketch_constraint_undo_tests { acadrust::types::Vector3::new(5.0, 0.0, 0.0), // exactly on `existing`'s end point acadrust::types::Vector3::new(10.0, 0.0, 0.0), )); - app.tabs[app.active_tab].active_cmd = Some(Box::new(crate::modules::draw::draw::line::LineCommand::new())); + app.tabs[app.active_tab].active_cmd = Some(Box::new( + crate::modules::draw::draw::line::LineCommand::new(), + )); let _ = app.apply_cmd_result(CmdResult::CommitEntity(new_line)); assert_eq!( app.tabs[app.active_tab] .scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .map(|s| s.constraints.len()) .unwrap_or(0), 0, @@ -7423,36 +7812,59 @@ mod sketch_constraint_undo_tests { /// Conflict resolution removes one flagged constraint and is undoable. #[test] - fn resolve_one_sketch_conflict_removes_a_flagged_constraint_and_is_undoable() { + fn resolve_one_parametric_conflict_removes_a_flagged_constraint_and_is_undoable() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0); - let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Horizontal, - refs: vec![SketchRef::whole(line)], + refs: vec![ParametricRef::whole(line)], driving_param: None, label: "Horizontal constraint", }); - let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Horizontal, - refs: vec![SketchRef::whole(line)], + refs: vec![ParametricRef::whole(line)], driving_param: None, label: "Horizontal constraint", }); // exact duplicate — flagged redundant once bumped - app.tabs[app.active_tab].scene.bump_entities(&[(line, crate::scene::ChangeKind::Modified)]); + app.tabs[app.active_tab] + .scene + .bump_entities(&[(line, crate::scene::ChangeKind::Modified)]); - let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap(); + let set = app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap(); assert_eq!(set.constraints.len(), 2); - assert_eq!(set.conflicts.len(), 1, "the duplicate should have been flagged"); + assert_eq!( + set.conflicts.len(), + 1, + "the duplicate should have been flagged" + ); - app.resolve_one_sketch_conflict(); - 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"); + app.resolve_one_parametric_conflict(); + let set = app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap(); + assert_eq!( + set.constraints.len(), + 1, + "one of the two duplicates should have been removed" + ); app.undo_steps(1); - let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap(); - assert_eq!(set.constraints.len(), 2, "undo should restore the removed constraint"); + let set = app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap(); + assert_eq!( + set.constraints.len(), + 2, + "undo should restore the removed constraint" + ); } /// `named_parameters_design.md` stage 4: applying the PARAMETERS editor's @@ -7468,24 +7880,38 @@ mod sketch_constraint_undo_tests { // The constraint is added *before* the parameter exists -- allowed, // same as `ParameterTable::set`'s own forward-reference tolerance; // it just doesn't resolve to anything until the parameter is defined. - let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Distance, - refs: vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], - driving_param: Some(crate::scene::named_parameters::DrivingValue::Named("target_len".to_string())), + refs: vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], + driving_param: Some(crate::scene::named_parameters::DrivingValue::Named( + "target_len".to_string(), + )), label: "Distance constraint", }); app.named_parameter_editor_rows = - vec![crate::ui::window::named_parameters::ParamEditorRow { name: "target_len".to_string(), formula: "8".to_string() }]; + vec![crate::ui::window::named_parameters::ParamEditorRow { + name: "target_len".to_string(), + formula: "8".to_string(), + }]; app.apply_named_parameter_editor_rows(); - assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(8.0)); + assert_eq!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .resolve("target_len"), + Ok(8.0) + ); let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { Some(acadrust::EntityType::Line(l)) => (l.start, l.end), other => panic!("expected a Line, got {other:?}"), }; let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); - assert!((len - 8.0).abs() < 1e-6, "length should track the applied parameter, got {len}"); + assert!( + (len - 8.0).abs() < 1e-6, + "length should track the applied parameter, got {len}" + ); // Redefining the parameter and re-applying should ripple again. app.named_parameter_editor_rows[0].formula = "3".to_string(); @@ -7495,10 +7921,19 @@ mod sketch_constraint_undo_tests { other => panic!("expected a Line, got {other:?}"), }; let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); - assert!((len - 3.0).abs() < 1e-6, "length should track the redefined parameter, got {len}"); + 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)); + 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:?}"), @@ -7506,7 +7941,13 @@ mod sketch_constraint_undo_tests { 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)); + 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 @@ -7522,41 +7963,89 @@ mod sketch_constraint_undo_tests { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let line = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); - let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Distance, - refs: vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], - driving_param: Some(crate::scene::named_parameters::DrivingValue::Named("len".to_string())), + refs: vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], + driving_param: Some(crate::scene::named_parameters::DrivingValue::Named( + "len".to_string(), + )), label: "Distance constraint", }); // Add: the first row is auto-named "param1". let _ = app.update(Message::PropParamAddNew); - assert!(app.tabs[app.active_tab].scene.named_parameters().contains("param1")); - let index = app.tabs[app.active_tab].scene.named_parameters().iter().position(|p| p.name == "param1").unwrap(); + assert!(app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("param1")); + let index = app.tabs[app.active_tab] + .scene + .named_parameters() + .iter() + .position(|p| p.name == "param1") + .unwrap(); // Rename param1 -> len (commit-on-submit, not per keystroke: input // alone must not touch the table yet). - let _ = app.update(Message::PropParamInput { index, field: ParamField::Name, value: "len".to_string() }); - assert!(app.tabs[app.active_tab].scene.named_parameters().contains("param1"), "typing alone must not commit"); - let _ = app.update(Message::PropParamCommit { index, field: ParamField::Name }); - assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("param1")); - assert!(app.tabs[app.active_tab].scene.named_parameters().contains("len")); + let _ = app.update(Message::PropParamInput { + index, + field: ParamField::Name, + value: "len".to_string(), + }); + assert!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("param1"), + "typing alone must not commit" + ); + let _ = app.update(Message::PropParamCommit { + index, + field: ParamField::Name, + }); + assert!(!app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("param1")); + assert!(app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("len")); // Redefine its formula to 8 and confirm the Distance constraint // (already referencing "len" by name, defined before this) re-solves. - let _ = app.update(Message::PropParamInput { index, field: ParamField::Formula, value: "8".to_string() }); - let _ = app.update(Message::PropParamCommit { index, field: ParamField::Formula }); - assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("len"), Ok(8.0)); + let _ = app.update(Message::PropParamInput { + index, + field: ParamField::Formula, + value: "8".to_string(), + }); + let _ = app.update(Message::PropParamCommit { + index, + field: ParamField::Formula, + }); + assert_eq!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .resolve("len"), + Ok(8.0) + ); let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { Some(acadrust::EntityType::Line(l)) => (l.start, l.end), other => panic!("expected a Line, got {other:?}"), }; let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); - assert!((len - 8.0).abs() < 1e-6, "length should track the redefined parameter, got {len}"); + assert!( + (len - 8.0).abs() < 1e-6, + "length should track the redefined parameter, got {len}" + ); // Delete: the row is gone from the table. let _ = app.update(Message::PropParamDelete(index)); - assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("len")); + assert!(!app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("len")); } /// A Constraints-section row click selects every entity the constraint @@ -7568,18 +8057,24 @@ mod sketch_constraint_undo_tests { let a = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); let b = add_line(&mut app, 0.0, 5.0, 10.0, 5.0); let unrelated = add_line(&mut app, 20.0, 20.0, 30.0, 20.0); - let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Parallel, - refs: vec![SketchRef::whole(a), SketchRef::whole(b)], + refs: vec![ParametricRef::whole(a), ParametricRef::whole(b)], driving_param: None, label: "Parallel constraint", }); - app.tabs[app.active_tab].scene.select_entity(unrelated, true); + app.tabs[app.active_tab] + .scene + .select_entity(unrelated, true); let _ = app.update(Message::PropConstraintLinkClick(vec![a, b])); - let selected: std::collections::HashSet = - app.tabs[app.active_tab].scene.selected_entities().into_iter().map(|(h, _)| h).collect(); + let selected: std::collections::HashSet = app.tabs[app.active_tab] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); assert_eq!(selected, std::collections::HashSet::from([a, b])); } @@ -7594,15 +8089,36 @@ mod sketch_constraint_undo_tests { let _ = app.automation_op(r#"{"op":"new"}"#); app.named_parameter_editor_rows = vec![ - crate::ui::window::named_parameters::ParamEditorRow { name: "good".to_string(), formula: "10".to_string() }, - crate::ui::window::named_parameters::ParamEditorRow { name: "bad".to_string(), formula: "1 +".to_string() }, + crate::ui::window::named_parameters::ParamEditorRow { + name: "good".to_string(), + formula: "10".to_string(), + }, + crate::ui::window::named_parameters::ParamEditorRow { + name: "bad".to_string(), + formula: "1 +".to_string(), + }, ]; app.apply_named_parameter_editor_rows(); - assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("good"), Ok(10.0)); - assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("bad"), "a malformed row must not be added"); + assert_eq!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .resolve("good"), + Ok(10.0) + ); assert!( - app.command_line.history.iter().any(|e| e.kind == crate::ui::command_line::EntryKind::Error), + !app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("bad"), + "a malformed row must not be added" + ); + assert!( + app.command_line + .history + .iter() + .any(|e| e.kind == crate::ui::command_line::EntryKind::Error), "a malformed row must be reported as an error, not silently dropped" ); } @@ -7616,15 +8132,40 @@ mod sketch_constraint_undo_tests { let _ = app.automation_op(r#"{"op":"new"}"#); app.named_parameter_editor_rows = vec![ - crate::ui::window::named_parameters::ParamEditorRow { name: "x".to_string(), formula: "1".to_string() }, - crate::ui::window::named_parameters::ParamEditorRow { name: "x".to_string(), formula: "2".to_string() }, - crate::ui::window::named_parameters::ParamEditorRow { name: "y".to_string(), formula: "3".to_string() }, + crate::ui::window::named_parameters::ParamEditorRow { + name: "x".to_string(), + formula: "1".to_string(), + }, + crate::ui::window::named_parameters::ParamEditorRow { + name: "x".to_string(), + formula: "2".to_string(), + }, + crate::ui::window::named_parameters::ParamEditorRow { + name: "y".to_string(), + formula: "3".to_string(), + }, ]; app.apply_named_parameter_editor_rows(); - assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("x"), "a duplicated name must not be added at all"); - assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("y"), Ok(3.0)); - assert!(app.command_line.history.iter().any(|e| e.kind == crate::ui::command_line::EntryKind::Error)); + assert!( + !app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("x"), + "a duplicated name must not be added at all" + ); + assert_eq!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .resolve("y"), + Ok(3.0) + ); + assert!(app + .command_line + .history + .iter() + .any(|e| e.kind == crate::ui::command_line::EntryKind::Error)); } /// Two endpoint picks resolve to a Coincident constraint. @@ -7641,12 +8182,18 @@ mod sketch_constraint_undo_tests { label: "Coincident constraint", }); - let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).expect("a constraint set should exist"); + let set = app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .expect("a constraint set should exist"); assert_eq!(set.constraints.len(), 1); let c = &set.constraints[0]; assert_eq!(c.kind, ConstraintKind::Coincident); let entities: Vec = c.refs.iter().map(|r| r.entity).collect(); - assert!(entities.contains(&a) && entities.contains(&b), "the constraint should reference both lines"); + assert!( + entities.contains(&a) && entities.contains(&b), + "the constraint should reference both lines" + ); } /// A pick that doesn't land near any real point (no object snap match) @@ -7667,7 +8214,7 @@ mod sketch_constraint_undo_tests { assert_eq!( app.tabs[app.active_tab] .scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .map(|s| s.constraints.len()) .unwrap_or(0), 0, @@ -7690,12 +8237,13 @@ mod delobj_tests { let profile = app.tabs[i] .scene .add_entity(acadrust::EntityType::Circle(circle)); - let path = app.tabs[i].scene.add_entity(acadrust::EntityType::Line( - acadrust::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(0.0, 0.0, 5.0), - ), - )); + let path = + app.tabs[i] + .scene + .add_entity(acadrust::EntityType::Line(acadrust::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(0.0, 0.0, 5.0), + ))); app.delete_objects = value; let _ = app.apply_cmd_result(CmdResult::SweepEntities { @@ -7706,14 +8254,18 @@ mod delobj_tests { color: [1.0; 4], }); - let created = app.tabs[i].scene.document.entities().any(|entity| match mode { - crate::command::ExtrudeMode::Solid => { - matches!(entity, acadrust::EntityType::Solid3D(_)) - } - crate::command::ExtrudeMode::Surface => { - matches!(entity, acadrust::EntityType::Surface(_)) - } - }); + let created = app.tabs[i] + .scene + .document + .entities() + .any(|entity| match mode { + crate::command::ExtrudeMode::Solid => { + matches!(entity, acadrust::EntityType::Solid3D(_)) + } + crate::command::ExtrudeMode::Surface => { + matches!(entity, acadrust::EntityType::Surface(_)) + } + }); assert!(created, "SWEEP must produce the requested result"); ( app.tabs[i].scene.document.get_entity(profile).is_some(), @@ -7752,41 +8304,93 @@ mod thicken_tests { fn thicken_preserves_sources_and_round_trips_results_with_one_undo() { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); - let body = cadkernel::brep::planar_region(Plane::XY, &[ - vec![Curve::Circle(Circle { centre: [0.0; 2], radius: 3.0 })], - ]).unwrap(); - let source = app.add_surface_model(acadrust::EntityType::Surface(Surface::new(SurfaceKind::Plane)), body); + let body = cadkernel::brep::planar_region( + Plane::XY, + &[vec![Curve::Circle(Circle { + centre: [0.0; 2], + radius: 3.0, + })]], + ) + .unwrap(); + let source = app.add_surface_model( + acadrust::EntityType::Surface(Surface::new(SurfaceKind::Plane)), + body, + ); assert!(!source.is_null()); let i = app.active_tab; - let invalid = app.tabs[i].scene.add_entity(acadrust::EntityType::Surface(Surface::new(SurfaceKind::Generic))); - let original = serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(); + let invalid = app.tabs[i] + .scene + .add_entity(acadrust::EntityType::Surface(Surface::new( + SurfaceKind::Generic, + ))); + let original = + serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(); let before = app.tabs[i].history.undo_stack.len(); - let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![source], distance: 0.0 }); + let _ = app.apply_cmd_result(CmdResult::ThickenEntities { + handles: vec![source], + distance: 0.0, + }); assert_eq!(app.tabs[i].history.undo_stack.len(), before); - let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![invalid, source], distance: 2.0 }); + let _ = app.apply_cmd_result(CmdResult::ThickenEntities { + handles: vec![invalid, source], + distance: 2.0, + }); assert_eq!(app.tabs[i].history.undo_stack.len(), before + 1); - let result = app.tabs[i].scene.document.entities().find_map(|entity| { - matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle) - }).unwrap(); - assert!(app.tabs[i].scene.document.solid_history_operation(result).is_some()); - assert_eq!(serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), original); - let expected = cadkernel::brep::analytic_mass_properties(&app.tabs[i].scene.solid_models[&result]).unwrap().volume; - let bytes = crate::io::save_to_bytes(&app.tabs[i].scene.document, "dwg", app.tabs[i].scene.document.version).unwrap(); + let result = app.tabs[i] + .scene + .document + .entities() + .find_map(|entity| { + matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle) + }) + .unwrap(); + assert!(app.tabs[i] + .scene + .document + .solid_history_operation(result) + .is_some()); + assert_eq!( + serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), + original + ); + let expected = + cadkernel::brep::analytic_mass_properties(&app.tabs[i].scene.solid_models[&result]) + .unwrap() + .volume; + let bytes = crate::io::save_to_bytes( + &app.tabs[i].scene.document, + "dwg", + app.tabs[i].scene.document.version, + ) + .unwrap(); let document = crate::io::load_bytes("thicken.dwg", bytes).unwrap(); let mut restored = crate::scene::Scene::new(); restored.document = document; restored.restore_solid_models(&[result]); - let actual = cadkernel::brep::analytic_mass_properties(&restored.solid_models[&result]).unwrap().volume; + let actual = cadkernel::brep::analytic_mass_properties(&restored.solid_models[&result]) + .unwrap() + .volume; assert!((expected - actual).abs() < 1e-8); app.undo_active_tab(); assert!(app.tabs[i].scene.document.get_entity(result).is_none()); - assert!(app.tabs[i].scene.document.solid_history_operation(result).is_none()); + assert!(app.tabs[i] + .scene + .document + .solid_history_operation(result) + .is_none()); assert!(app.tabs[i].scene.document.get_entity(source).is_some()); assert!(app.tabs[i].scene.document.get_entity(invalid).is_some()); app.redo_active_tab(); assert!(app.tabs[i].scene.document.get_entity(result).is_some()); - assert!(app.tabs[i].scene.document.solid_history_operation(result).is_some()); - assert_eq!(serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), original); + assert!(app.tabs[i] + .scene + .document + .solid_history_operation(result) + .is_some()); + assert_eq!( + serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), + original + ); } #[test] @@ -7800,12 +8404,9 @@ mod thicken_tests { Parameterization::Chord, ) .unwrap(); - let body = cadkernel::brep::extrude_surface( - Plane::XY, - &[Curve::Nurbs(profile)], - [0.0, 0.0, 2.0], - ) - .unwrap(); + let body = + cadkernel::brep::extrude_surface(Plane::XY, &[Curve::Nurbs(profile)], [0.0, 0.0, 2.0]) + .unwrap(); let source = app.add_surface_model( acadrust::EntityType::Surface(Surface::new(SurfaceKind::Generic)), body, @@ -7822,8 +8423,7 @@ mod thicken_tests { .document .entities() .filter_map(|entity| { - matches!(entity, acadrust::EntityType::Solid3D(_)) - .then_some(entity.common().handle) + matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle) }) .collect::>(); assert_eq!(solids.len(), 1); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index dc59babfa..e45c8c1a4 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -29,12 +29,7 @@ impl OpenCADStudio { _ => None, }) .collect(); - let cmd_obj = MlineCommand::with_styles( - styles, - style_name, - scale, - justification, - ); + let cmd_obj = MlineCommand::with_styles(styles, style_name, scale, justification); self.command_line.push_info(&cmd_obj.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); } @@ -133,25 +128,29 @@ impl OpenCADStudio { if rest.is_empty() { // List attributes. if ins.attributes.is_empty() { - self.command_line.push_output(crate::tf!( - " Insert {:x}: no attributes.", - sh.value() - ).as_ref()); + self.command_line.push_output( + crate::tf!(" Insert {:x}: no attributes.", sh.value()) + .as_ref(), + ); } else { for attr in &ins.attributes { - self.command_line.push_output(crate::tf!( - " [{tag}] = {val}", - tag = attr.tag, - val = attr.get_value() - ).as_ref()); + self.command_line.push_output( + crate::tf!( + " [{tag}] = {val}", + tag = attr.tag, + val = attr.get_value() + ) + .as_ref(), + ); } } } } } if !found_any { - self.command_line - .push_error(crate::t!("ATTEDIT: no Insert entities in selection.").as_ref()); + self.command_line.push_error( + crate::t!("ATTEDIT: no Insert entities in selection.").as_ref(), + ); } // If tag + value supplied, mutate attributes. if parts.len() == 2 && !parts[0].is_empty() { @@ -179,13 +178,17 @@ impl OpenCADStudio { } if changed > 0 { self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( + self.command_line.push_output( + crate::tf!( "ATTEDIT: updated {changed} attribute(s) [{tag_up}] = {new_val}." - ).as_ref()); + ) + .as_ref(), + ); } else { - self.command_line.push_error(crate::tf!( - "ATTEDIT: tag '{tag_up}' not found in selection." - ).as_ref()); + self.command_line.push_error( + crate::tf!("ATTEDIT: tag '{tag_up}' not found in selection.") + .as_ref(), + ); } } } @@ -243,9 +246,10 @@ impl OpenCADStudio { } } self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "ATTDISP {sub}: {count} attribute definition(s) updated." - ).as_ref()); + self.command_line.push_output( + crate::tf!("ATTDISP {sub}: {count} attribute definition(s) updated.") + .as_ref(), + ); } _ => { self.command_line @@ -388,8 +392,10 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } None => { - self.command_line - .push_info(crate::t!("ARC Continue No previous line or arc to continue.").as_ref()); + self.command_line.push_info( + crate::t!("ARC Continue No previous line or arc to continue.") + .as_ref(), + ); } } } @@ -528,16 +534,26 @@ impl OpenCADStudio { if count > 0 { self.tabs[i].dirty = true; } - self.command_line - .push_output(&crate::tf!("CENTERRESET: {count} center object(s) updated.")); + self.command_line.push_output(&crate::tf!( + "CENTERRESET: {count} center object(s) updated." + )); } "CENTERREASSOCIATE" => { let handles = self.tabs[i].scene.selected_handles_in_order(); - let mark_targets: Vec<_> = handles.iter().copied().filter(|handle| { - let Some(acadrust::EntityType::Line(line)) = self.tabs[i].scene.document.get_entity(*handle) else { return false; }; - acadrust::entities::CenterMarkAssociation::read(&line.common.extended_data).is_some() - }).collect(); + let mark_targets: Vec<_> = handles + .iter() + .copied() + .filter(|handle| { + let Some(acadrust::EntityType::Line(line)) = + self.tabs[i].scene.document.get_entity(*handle) + else { + return false; + }; + acadrust::entities::CenterMarkAssociation::read(&line.common.extended_data) + .is_some() + }) + .collect(); if mark_targets.len() == 1 && handles.len() == 1 { use crate::modules::draw::draw::dimcenter::CenterMarkReassociateCommand; let new_cmd = CenterMarkReassociateCommand::new(mark_targets[0]); @@ -546,25 +562,35 @@ impl OpenCADStudio { return Some(self.finish_dispatch(cmd)); } self.push_undo_snapshot(i, "CENTERREASSOCIATE"); - let count = self.tabs[i].scene.set_centerline_association(&handles, true) - + self.tabs[i].scene.set_center_mark_association(&handles, true); + let count = self.tabs[i] + .scene + .set_centerline_association(&handles, true) + + self.tabs[i] + .scene + .set_center_mark_association(&handles, true); if count > 0 { self.tabs[i].dirty = true; } - self.command_line - .push_output(&crate::tf!("CENTERREASSOCIATE: {count} center object(s) associated.")); + self.command_line.push_output(&crate::tf!( + "CENTERREASSOCIATE: {count} center object(s) associated." + )); } "CENTERDISASSOCIATE" => { let handles = self.tabs[i].scene.selected_handles_in_order(); self.push_undo_snapshot(i, "CENTERDISASSOCIATE"); - let count = self.tabs[i].scene.set_centerline_association(&handles, false) - + self.tabs[i].scene.set_center_mark_association(&handles, false); + let count = self.tabs[i] + .scene + .set_centerline_association(&handles, false) + + self.tabs[i] + .scene + .set_center_mark_association(&handles, false); if count > 0 { self.tabs[i].dirty = true; } - self.command_line - .push_output(&crate::tf!("CENTERDISASSOCIATE: {count} center object(s) detached.")); + self.command_line.push_output(&crate::tf!( + "CENTERDISASSOCIATE: {count} center object(s) detached." + )); } "DIMCENTER" => { @@ -602,15 +628,20 @@ impl OpenCADStudio { self.command_line.push_info(&selection.prompt()); self.tabs[i].active_cmd = Some(Box::new(selection)); } else { - let replacements = self.tabs[i].scene.selected_entities().into_iter() + let replacements = self.tabs[i] + .scene + .selected_entities() + .into_iter() .filter(|(handle, _)| !self.tabs[i].scene.is_layer_locked(*handle)) - .filter_map(|(handle, entity)| ReverseCommand::reversed(entity) - .map(|reversed| (handle, vec![reversed]))) + .filter_map(|(handle, entity)| { + ReverseCommand::reversed(entity) + .map(|reversed| (handle, vec![reversed])) + }) .collect::>(); if !replacements.is_empty() { - return Some(self.apply_cmd_result(crate::command::CmdResult::ReplaceMany( - replacements, Vec::new(), - ))); + return Some(self.apply_cmd_result( + crate::command::CmdResult::ReplaceMany(replacements, Vec::new()), + )); } } } @@ -766,9 +797,7 @@ impl OpenCADStudio { glam::DVec3::from_array(storage.x_axis), glam::DVec3::from_array(storage.y_axis), ); - let boundary_sources = self.tabs[i] - .scene - .boundary_sources_on_plane(plane, 1.0e-6); + let boundary_sources = self.tabs[i].scene.boundary_sources_on_plane(plane, 1.0e-6); let outlines = crate::scene::boundary_faces(&boundary_sources, 1.0e-6); let selected = self.tabs[i] .scene @@ -776,20 +805,14 @@ impl OpenCADStudio { .into_iter() .map(|(handle, _)| handle) .collect::>(); - let inherited = selected - .iter() - .find_map(|handle| { - let model = self.tabs[i].scene.hatches.get(handle)?.clone(); - let common = self.tabs[i].scene.document.get_entity(*handle)?.common(); - Some((model, common.color.clone(), common.transparency)) - }); - let new_cmd = HatchCommand::new( - outlines, - boundary_sources, - selected, - inherited, - plane, - ).with_origin(self.tabs[i].scene.document.hatch_origin()); + let inherited = selected.iter().find_map(|handle| { + let model = self.tabs[i].scene.hatches.get(handle)?.clone(); + let common = self.tabs[i].scene.document.get_entity(*handle)?.common(); + Some((model, common.color.clone(), common.transparency)) + }); + let new_cmd = + HatchCommand::new(outlines, boundary_sources, selected, inherited, plane) + .with_origin(self.tabs[i].scene.document.hatch_origin()); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); self.refresh_area_preview(i); @@ -804,11 +827,11 @@ impl OpenCADStudio { if let Some(model) = self.tabs[i].scene.hatches.get(&h).cloned() { let entity = self.tabs[i].scene.document.get_entity(h); let annotative = entity.is_some_and(|entity| { - crate::scene::annotative::is_annotative( - &self.tabs[i].scene.document, - entity, - ) - }); + crate::scene::annotative::is_annotative( + &self.tabs[i].scene.document, + entity, + ) + }); let (scale, angle) = match entity { Some(acadrust::EntityType::Hatch(hatch)) => ( hatch.pattern_scale as f32, @@ -822,15 +845,28 @@ impl OpenCADStudio { scale, angle, annotative, - ).with_appearance(entity,self.tabs[i].scene.document.header.current_entity_color,self.tabs[i].scene.document.current_entity_transparency()).with_origin(self.tabs[i].scene.document.hatch_origin()); + ) + .with_appearance( + entity, + self.tabs[i].scene.document.header.current_entity_color, + self.tabs[i].scene.document.current_entity_transparency(), + ) + .with_origin(self.tabs[i].scene.document.hatch_origin()); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { - self.command_line - .push_error(crate::t!("HATCHEDIT: selected entity is not a hatch.").as_ref()); + self.command_line.push_error( + crate::t!("HATCHEDIT: selected entity is not a hatch.").as_ref(), + ); } } else { - let cmd = HatcheditCommand::new().with_appearance(None,self.tabs[i].scene.document.header.current_entity_color,self.tabs[i].scene.document.current_entity_transparency()).with_origin(self.tabs[i].scene.document.hatch_origin()); + let cmd = HatcheditCommand::new() + .with_appearance( + None, + self.tabs[i].scene.document.header.current_entity_color, + self.tabs[i].scene.document.current_entity_transparency(), + ) + .with_origin(self.tabs[i].scene.document.hatch_origin()); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } @@ -854,9 +890,7 @@ impl OpenCADStudio { } else { crate::command::WorkingPlane::default() }; - let sources = self.tabs[i] - .scene - .boundary_sources_on_plane(plane, 1.0e-6); + let sources = self.tabs[i].scene.boundary_sources_on_plane(plane, 1.0e-6); let selected = self.tabs[i] .scene .selected_entities() @@ -895,7 +929,8 @@ impl OpenCADStudio { SplineCommand::control_vertices() } else { SplineCommand::new() - }.with_document(&self.tabs[i].scene.document); + } + .with_document(&self.tabs[i].scene.document); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } @@ -935,8 +970,7 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { use crate::modules::draw::modify::mirror::MirrorCommand; - let (wires, text_ghosts) = - self.tabs[i].scene.mirror_preview_parts(&handles); + let (wires, text_ghosts) = self.tabs[i].scene.mirror_preview_parts(&handles); let mirror_text = self.tabs[i].scene.document.header.mirror_text; let new_cmd = MirrorCommand::new(handles, wires, text_ghosts, mirror_text); self.command_line.push_info(&new_cmd.prompt()); @@ -986,10 +1020,10 @@ impl OpenCADStudio { self.command_line.push_info(&sel.prompt()); self.tabs[i].active_cmd = Some(Box::new(sel)); } else { - let scope = self.tabs[i].current_sketch_scope(); + let scope = self.tabs[i].current_parametric_scope(); let dimensional = cmd.starts_with("DC"); let visible = !matches!(cmd, "GCHIDE" | "DCHIDE"); - let count = self.tabs[i].scene.set_sketch_constraint_visibility( + let count = self.tabs[i].scene.set_parametric_constraint_visibility( scope, Some(&handles), dimensional, @@ -1002,19 +1036,19 @@ impl OpenCADStudio { } "GCRESET" => { - let scope = self.tabs[i].current_sketch_scope(); + let scope = self.tabs[i].current_parametric_scope(); let count = self.tabs[i] .scene - .set_sketch_constraint_visibility(scope, None, false, true); + .set_parametric_constraint_visibility(scope, None, false, true); self.command_line .push_output(format!("{} constraint indicator(s) reset.", count).as_str()); } "GCSHOWALL" | "GCHIDEALL" | "DCSHOWALL" | "DCHIDEALL" => { - let scope = self.tabs[i].current_sketch_scope(); + let scope = self.tabs[i].current_parametric_scope(); let dimensional = cmd.starts_with("DC"); let visible = matches!(cmd, "GCSHOWALL" | "DCSHOWALL"); - let count = self.tabs[i].scene.set_sketch_constraint_visibility( + let count = self.tabs[i].scene.set_parametric_constraint_visibility( scope, None, dimensional, @@ -1032,11 +1066,8 @@ impl OpenCADStudio { self.command_line.push_info(&sel.prompt()); self.tabs[i].active_cmd = Some(Box::new(sel)); } else { - let scope = self.tabs[i].current_sketch_scope(); - let Some(before) = self.tabs[i] - .scene - .sketch_constraint_set(scope) - .cloned() + let scope = self.tabs[i].current_parametric_scope(); + let Some(before) = self.tabs[i].scene.parametric_constraint_set(scope).cloned() else { self.command_line.push_output("No constraints found."); return None; @@ -1061,8 +1092,8 @@ impl OpenCADStudio { let pending = self.begin_undo(i, "Delete constraints", handles.len(), true); self.tabs[i] .scene - .record_undo_sketch_constraints_before(scope, before); - let set = self.tabs[i].scene.sketch_constraint_set_mut(scope); + .record_undo_parametric_constraints_before(scope, before); + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); for id in &ids { set.remove(*id); } @@ -1090,10 +1121,10 @@ impl OpenCADStudio { self.command_line.push_info(&sel.prompt()); self.tabs[i].active_cmd = Some(Box::new(sel)); } else { - let scope = self.tabs[i].current_sketch_scope(); + let scope = self.tabs[i].current_parametric_scope(); let inferred = self.tabs[i] .scene - .inferred_sketch_constraints(scope, &handles); + .inferred_parametric_constraints(scope, &handles); if inferred.is_empty() { self.command_line .push_output("No supported geometric relations were found."); @@ -1101,20 +1132,22 @@ impl OpenCADStudio { } let before = self.tabs[i] .scene - .sketch_constraint_set(scope) + .parametric_constraint_set(scope) .cloned() .unwrap_or_else(|| { - crate::scene::sketch_constraints::SketchConstraintSet::new(scope) + crate::scene::parametric_constraints::ParametricConstraintSet::new( + scope, + ) }); let pending = self.begin_undo(i, "Auto constrain", handles.len(), true); self.tabs[i] .scene - .record_undo_sketch_constraints_before(scope, before); + .record_undo_parametric_constraints_before(scope, before); let count = inferred.len(); for (kind, refs) in inferred { self.tabs[i] .scene - .sketch_constraint_set_mut(scope) + .parametric_constraint_set_mut(scope) .add(kind, refs, None); } let changes: Vec<_> = handles @@ -1142,14 +1175,13 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(sel)); } else { let Some(refs) = self.tabs[i].scene.smooth_constraint_refs(&handles) else { - self.command_line.push_output( - "Select one open spline and one open target curve.", - ); + self.command_line + .push_output("Select one open spline and one open target curve."); return None; }; use crate::command::CmdResult; - use crate::scene::sketch_constraints::ConstraintKind; - return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { + use crate::scene::parametric_constraints::ConstraintKind; + return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Smooth, refs, driving_param: None, @@ -1166,7 +1198,7 @@ impl OpenCADStudio { self.command_line.push_info(&sel.prompt()); self.tabs[i].active_cmd = Some(Box::new(sel)); } else { - let scope = self.tabs[i].current_sketch_scope(); + let scope = self.tabs[i].current_parametric_scope(); let conversions: Vec<_> = handles .iter() .copied() @@ -1179,23 +1211,24 @@ impl OpenCADStudio { )?; self.tabs[i] .scene - .validate_sketch_constraint(kind, &refs, Some(&value)) + .validate_parametric_constraint(kind, &refs, Some(&value)) .ok()?; Some((handle, kind, refs, value)) }) .collect(); if conversions.is_empty() { - self.command_line.push_output( - "No supported associative dimensions were selected.", - ); + self.command_line + .push_output("No supported associative dimensions were selected."); return None; } let before = self.tabs[i] .scene - .sketch_constraint_set(scope) + .parametric_constraint_set(scope) .cloned() .unwrap_or_else(|| { - crate::scene::sketch_constraints::SketchConstraintSet::new(scope) + crate::scene::parametric_constraints::ParametricConstraintSet::new( + scope, + ) }); let mut touched: Vec<_> = conversions .iter() @@ -1215,12 +1248,13 @@ impl OpenCADStudio { ); self.tabs[i] .scene - .record_undo_sketch_constraints_before(scope, before); + .record_undo_parametric_constraints_before(scope, before); for (_, kind, refs, value) in conversions { - self.tabs[i] - .scene - .sketch_constraint_set_mut(scope) - .add(kind, refs, Some(value)); + self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + kind, + refs, + Some(value), + ); } self.tabs[i].scene.erase_entities(&dimensions); let changes: Vec<_> = touched @@ -1251,15 +1285,15 @@ impl OpenCADStudio { .push_output("Select exactly one entity, then run this constraint again."); } else { use crate::command::CmdResult; - use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; let (kind, label) = match cmd { "HCONSTRAINT" => (ConstraintKind::Horizontal, "Horizontal constraint"), "VCONSTRAINT" => (ConstraintKind::Vertical, "Vertical constraint"), _ => (ConstraintKind::Fixed, "Fixed constraint"), }; - return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { + return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { kind, - refs: vec![SketchRef::whole(handles[0])], + refs: vec![ParametricRef::whole(handles[0])], driving_param: None, label, })); @@ -1280,7 +1314,8 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" | "NRCONSTRAINT" => { + "PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" + | "NRCONSTRAINT" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -1293,18 +1328,23 @@ impl OpenCADStudio { ); } else { use crate::command::CmdResult; - use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; let (kind, label) = match cmd { "PCONSTRAINT" => (ConstraintKind::Parallel, "Parallel constraint"), - "QCONSTRAINT" => (ConstraintKind::Perpendicular, "Perpendicular constraint"), + "QCONSTRAINT" => { + (ConstraintKind::Perpendicular, "Perpendicular constraint") + } "TCONSTRAINT" => (ConstraintKind::Tangent, "Tangent constraint"), "LCONSTRAINT" => (ConstraintKind::Colinear, "Colinear constraint"), "NRCONSTRAINT" => (ConstraintKind::Normal, "Normal constraint"), _ => (ConstraintKind::Equal, "Equal constraint"), }; - return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { + return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { kind, - refs: vec![SketchRef::whole(handles[0]), SketchRef::whole(handles[1])], + refs: vec![ + ParametricRef::whole(handles[0]), + ParametricRef::whole(handles[1]), + ], driving_param: None, label, })); @@ -1319,13 +1359,18 @@ impl OpenCADStudio { self.command_line.push_info(&sel.prompt()); self.tabs[i].active_cmd = Some(Box::new(sel)); } else if handles.len() != 2 { - self.command_line.push_output("Select exactly two circles/arcs, then run this constraint again."); + self.command_line.push_output( + "Select exactly two circles/arcs, then run this constraint again.", + ); } else { use crate::command::CmdResult; - use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; - return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; + return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Concentric, - refs: vec![SketchRef::center(handles[0]), SketchRef::center(handles[1])], + refs: vec![ + ParametricRef::center(handles[0]), + ParametricRef::center(handles[1]), + ], driving_param: None, label: "Concentric constraint", })); @@ -1345,10 +1390,14 @@ impl OpenCADStudio { ); } else { use crate::command::CmdResult; - use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; - return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; + return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Symmetric, - refs: vec![SketchRef::center(handles[0]), SketchRef::center(handles[1]), SketchRef::whole(handles[2])], + refs: vec![ + ParametricRef::center(handles[0]), + ParametricRef::center(handles[1]), + ParametricRef::whole(handles[2]), + ], driving_param: None, label: "Symmetric constraint", })); @@ -1368,13 +1417,26 @@ impl OpenCADStudio { ); } else { use crate::modules::parametric::PointOnEntityConstraintCommand; - use crate::scene::sketch_constraints::ConstraintKind; + use crate::scene::parametric_constraints::ConstraintKind; let (name, kind, label) = match cmd { - "CPCONSTRAINT" => ("CPCONSTRAINT", ConstraintKind::CenterPoint, "Center point constraint"), - "MPCONSTRAINT" => ("MPCONSTRAINT", ConstraintKind::Midpoint, "Midpoint constraint"), - _ => ("OCCONSTRAINT", ConstraintKind::PointOnCurve, "Point on curve constraint"), + "CPCONSTRAINT" => ( + "CPCONSTRAINT", + ConstraintKind::CenterPoint, + "Center point constraint", + ), + "MPCONSTRAINT" => ( + "MPCONSTRAINT", + ConstraintKind::Midpoint, + "Midpoint constraint", + ), + _ => ( + "OCCONSTRAINT", + ConstraintKind::PointOnCurve, + "Point on curve constraint", + ), }; - let new_cmd = PointOnEntityConstraintCommand::new(name, kind, handles[0], label); + let new_cmd = + PointOnEntityConstraintCommand::new(name, kind, handles[0], label); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } @@ -1389,8 +1451,9 @@ impl OpenCADStudio { self.command_line.push_info(&sel.prompt()); self.tabs[i].active_cmd = Some(Box::new(sel)); } else if handles.len() != 1 { - self.command_line - .push_output("Select exactly one line or circle, then run this constraint again."); + self.command_line.push_output( + "Select exactly one line or circle, then run this constraint again.", + ); } else { use crate::modules::parametric::{DistanceConstraintCommand, DistanceMode}; let (command_name, mode) = match cmd { @@ -1412,7 +1475,9 @@ impl OpenCADStudio { self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - None => self.command_line.push_output("Select a line or a circle for a distance constraint."), + None => self + .command_line + .push_output("Select a line or a circle for a distance constraint."), } } } @@ -1445,7 +1510,9 @@ impl OpenCADStudio { self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - None => self.command_line.push_output("Select two lines for an angle constraint."), + None => self + .command_line + .push_output("Select two lines for an angle constraint."), } } } @@ -1457,8 +1524,7 @@ impl OpenCADStudio { self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "BOX" | "WEDGE" | "CONE" | "SPHERE" | "PYRAMID" | "PYR" - | "TORUS" => { + "BOX" | "WEDGE" | "CONE" | "SPHERE" | "PYRAMID" | "PYR" | "TORUS" => { use crate::modules::model::primitive_cmd::PrimitiveCommand; let new_cmd = PrimitiveCommand::new(cmd); self.command_line.push_info(&new_cmd.prompt()); @@ -1625,54 +1691,69 @@ impl OpenCADStudio { continue; }; let mut region = Region::new(); - region.point_of_reference = Vector3::new( - plane.origin[0], - plane.origin[1], - plane.origin[2], - ); + region.point_of_reference = + Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]); region.common.layer = self.tabs[i].active_layer.clone(); regions.push((region, body)); sources.push(*handle); } } // Open inputs must share one geometric plane, independently of the UCS. - let open_curves: Vec<_> = self.tabs[i].scene.selected_entities().iter() + let open_curves: Vec<_> = self.tabs[i] + .scene + .selected_entities() + .iter() .filter(|(handle, _)| !sources.contains(handle)) .filter_map(|(_, entity)| crate::entities::curve::entity_curve(entity)) .collect(); let open_plane = cadkernel::space::common_curve_plane(&open_curves, 1.0e-6); let open_plane_rejected = !open_curves.is_empty() && open_plane.is_none(); if let Some(plane) = open_plane { - let working_plane = crate::command::WorkingPlane::new( - glam::DVec3::from_array(plane.origin), - glam::DVec3::from_array(plane.x_axis), - glam::DVec3::from_array(plane.y_axis), - ); - let selected_handles: rustc_hash::FxHashSet<_> = self.tabs[i].scene - .selected_entities().iter().map(|(handle, _)| *handle).collect(); - let mut boundary_sources = self.tabs[i].scene - .boundary_sources_on_plane(working_plane, 1.0e-6); - boundary_sources.retain(|handle, _| { - selected_handles.contains(handle) && !sources.contains(handle) - }); - for ring in crate::scene::boundary_faces(&boundary_sources, 1.0e-6) { - let paths = crate::scene::exact_hatch_paths( - std::slice::from_ref(&ring), &[true], &boundary_sources, 1.0e-6, + let working_plane = crate::command::WorkingPlane::new( + glam::DVec3::from_array(plane.origin), + glam::DVec3::from_array(plane.x_axis), + glam::DVec3::from_array(plane.y_axis), ); - let Some(path) = paths.first() else { continue; }; - let Some(curves) = path.edges.iter() - .map(crate::entities::hatch::edge_curve).collect::>>() - else { continue; }; - let Some(body) = cadkernel::brep::planar_region(plane, &[curves]) - else { continue; }; - let mut region = Region::new(); - region.point_of_reference = Vector3::new( - plane.origin[0], plane.origin[1], plane.origin[2], - ); - region.common.layer = self.tabs[i].active_layer.clone(); - regions.push((region, body)); - sources.extend(crate::scene::ring_source_handles(&ring, &boundary_sources)); - } + let selected_handles: rustc_hash::FxHashSet<_> = self.tabs[i] + .scene + .selected_entities() + .iter() + .map(|(handle, _)| *handle) + .collect(); + let mut boundary_sources = self.tabs[i] + .scene + .boundary_sources_on_plane(working_plane, 1.0e-6); + boundary_sources.retain(|handle, _| { + selected_handles.contains(handle) && !sources.contains(handle) + }); + for ring in crate::scene::boundary_faces(&boundary_sources, 1.0e-6) { + let paths = crate::scene::exact_hatch_paths( + std::slice::from_ref(&ring), + &[true], + &boundary_sources, + 1.0e-6, + ); + let Some(path) = paths.first() else { + continue; + }; + let Some(curves) = path + .edges + .iter() + .map(crate::entities::hatch::edge_curve) + .collect::>>() + else { + continue; + }; + let Some(body) = cadkernel::brep::planar_region(plane, &[curves]) else { + continue; + }; + let mut region = Region::new(); + region.point_of_reference = + Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]); + region.common.layer = self.tabs[i].active_layer.clone(); + regions.push((region, body)); + sources.extend(crate::scene::ring_source_handles(&ring, &boundary_sources)); + } } else if open_plane_rejected { self.command_line.push_error( "REGION: open objects must form coplanar, noncollinear boundaries.", @@ -1720,8 +1801,10 @@ impl OpenCADStudio { let sides = nums.get(2).map(|s| *s as usize).unwrap_or(4); return Some(self.solid_pyramid(nums[0], nums[1], sides)); } - self.command_line - .push_info(crate::t!("Usage: PYRAMID [sides] (default 4 sides)").as_ref()); + self.command_line.push_info( + crate::t!("Usage: PYRAMID [sides] (default 4 sides)") + .as_ref(), + ); } // SECTION [X|Y|Z] — draw the cross-section outline of the solid. @@ -1756,7 +1839,10 @@ impl OpenCADStudio { match parts.get(val_idx).and_then(|s| s.parse::().ok()) { Some(v) => return Some(self.solid_section(axis, v)), None => self.command_line.push_info( - crate::t!("Usage: SECTION [X|Y|Z] (cross-sections the selected solid)").as_ref(), + crate::t!( + "Usage: SECTION [X|Y|Z] (cross-sections the selected solid)" + ) + .as_ref(), ), } } @@ -1909,7 +1995,8 @@ impl OpenCADStudio { match angle { Some(a) => return Some(self.solid_rotate3d(axis, a)), None => self.command_line.push_info( - crate::t!("Usage: 3DROTATE [X|Y|Z] (rotates the selected solid)").as_ref(), + crate::t!("Usage: 3DROTATE [X|Y|Z] (rotates the selected solid)") + .as_ref(), ), } } @@ -1951,11 +2038,12 @@ impl OpenCADStudio { } }) }); - let (centre, radius) = bounds.map_or((glam::DVec3::ZERO, 10.0), |(min, max)| { - let min = glam::DVec3::from_array(min); - let max = glam::DVec3::from_array(max); - ((min + max) * 0.5, (max - min).length().max(2.0) * 0.65) - }); + let (centre, radius) = + bounds.map_or((glam::DVec3::ZERO, 10.0), |(min, max)| { + let min = glam::DVec3::from_array(min); + let max = glam::DVec3::from_array(max); + ((min + max) * 0.5, (max - min).length().max(2.0) * 0.65) + }); (targets, centre, radius, scene.active_gaze_dir().as_dvec3()) }; let c = SliceCommand::new(targets, view_normal, centre, radius); @@ -1990,7 +2078,10 @@ impl OpenCADStudio { return Some(self.slice_selected(plane, Some(side))); } None => self.command_line.push_info( - crate::t!("Usage: SLICE [X|Y|Z] [TOP|BOTTOM] (cuts the selected solid)").as_ref(), + crate::t!( + "Usage: SLICE [X|Y|Z] [TOP|BOTTOM] (cuts the selected solid)" + ) + .as_ref(), ), } } @@ -2006,8 +2097,7 @@ impl OpenCADStudio { let styles = scene.document.text_styles.iter().cloned().collect(); (defaults, styles, annotation_multiplier) }; - let new_cmd = - TextCommand::with_defaults(defaults, styles, annotation_multiplier); + let new_cmd = TextCommand::with_defaults(defaults, styles, annotation_multiplier); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } @@ -2092,9 +2182,15 @@ mod region_tests { app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 3"}"#); app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#); let i = app.active_tab; - let sources: Vec<_> = app.tabs[i].scene.document.entities() + let sources: Vec<_> = app.tabs[i] + .scene + .document + .entities() .map(|entity| { - (entity.common().handle, matches!(entity, acadrust::EntityType::Circle(_))) + ( + entity.common().handle, + matches!(entity, acadrust::EntityType::Circle(_)), + ) }) .collect(); assert_eq!(sources.len(), 2); @@ -2110,7 +2206,10 @@ mod region_tests { !delete_sources || !is_circle, ); } - let region_count = app.tabs[i].scene.document.entities() + let region_count = app.tabs[i] + .scene + .document + .entities() .filter(|entity| matches!(entity, acadrust::EntityType::Region(_))) .count(); assert_eq!(region_count, 1); diff --git a/src/app/document.rs b/src/app/document.rs index 6bdcd7329..50d4cfc4b 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -6,11 +6,11 @@ use crate::scene::pick::grip::GripEdit; use crate::scene::GripDef; use crate::scene::{ObjectIsolationState, Scene}; use crate::snap::SnapResult; +use crate::t; use crate::ui::{LayerPanel, PropertiesPanel}; use acadrust::tables::{normalize_name, Ucs}; use acadrust::{CadDocument, EntityType, Handle}; use iced; -use crate::t; use std::any::Any; use std::collections::HashMap; use std::path::PathBuf; @@ -240,14 +240,18 @@ impl DocumentTab { .and_then(|index| self.block_edits.get_mut(index)) } - /// The [`crate::scene::sketch_constraints::SketchScope`] a new persistent + /// The [`crate::scene::parametric_constraints::ParametricScope`] a new persistent /// constraint should attach to right now — whatever's actually being /// edited: the open block definition if a BEDIT session is active, model /// space otherwise. - pub(super) fn current_sketch_scope(&self) -> crate::scene::sketch_constraints::SketchScope { + pub(super) fn current_parametric_scope( + &self, + ) -> crate::scene::parametric_constraints::ParametricScope { match self.active_block_edit_session() { - Some(session) => crate::scene::sketch_constraints::SketchScope::Block(session.br_handle), - None => crate::scene::sketch_constraints::SketchScope::ModelSpace, + Some(session) => { + crate::scene::parametric_constraints::ParametricScope::Block(session.br_handle) + } + None => crate::scene::parametric_constraints::ParametricScope::ModelSpace, } } @@ -291,8 +295,7 @@ impl DocumentTab { u.origin = h.model_space_ucs_origin; u.x_axis = h.model_space_ucs_x_axis; u.y_axis = h.model_space_ucs_y_axis; - if h.model_space_ucs_name.is_empty() - && super::helpers::UcsXform::from_ucs(&u).is_identity() + if h.model_space_ucs_name.is_empty() && super::helpers::UcsXform::from_ucs(&u).is_identity() { None } else { @@ -475,31 +478,30 @@ impl DocumentTab { } } } else { - let (origin, x_axis, y_axis, elevation, ortho, named, base) = - match &self.active_ucs { - Some(ucs) => ( - ucs.origin, - ucs.x_axis, - ucs.y_axis, - ucs.elevation, - ucs.ortho_type, - if ucs.named_ucs_handle.is_valid() { - ucs.named_ucs_handle - } else { - ucs.handle - }, - ucs.base_ucs_handle, - ), - None => ( - Vector3::ZERO, - Vector3::UNIT_X, - Vector3::UNIT_Y, - 0.0, - 0, - Handle::NULL, - Handle::NULL, - ), - }; + let (origin, x_axis, y_axis, elevation, ortho, named, base) = match &self.active_ucs { + Some(ucs) => ( + ucs.origin, + ucs.x_axis, + ucs.y_axis, + ucs.elevation, + ucs.ortho_type, + if ucs.named_ucs_handle.is_valid() { + ucs.named_ucs_handle + } else { + ucs.handle + }, + ucs.base_ucs_handle, + ), + None => ( + Vector3::ZERO, + Vector3::UNIT_X, + Vector3::UNIT_Y, + 0.0, + 0, + Handle::NULL, + Handle::NULL, + ), + }; for object in self.scene.document.objects.values_mut() { let acadrust::objects::ObjectType::Layout(layout) = object else { continue; @@ -656,7 +658,7 @@ impl DocumentTab { #[derive(Clone)] pub(super) enum HistorySnapshot { // Boxed: `DeltaSnapshot` grew past the other variants once - // `sketch_constraints` (ERASE-undo fix) was added, and every undo/redo + // `parametric_constraints` (ERASE-undo fix) was added, and every undo/redo // stack slot costs as much as this enum's largest variant regardless of // which one it actually holds. Delta(Box), @@ -687,41 +689,33 @@ impl HistorySnapshot { ) .saturating_add(d.selected_before.len().saturating_mul(16)) .saturating_add(d.selected_after.len().saturating_mul(16)) + .saturating_add(d.active_layer.as_ref().map_or(0, |(before, after)| { + before.len().saturating_add(after.len()) + })) .saturating_add( - d.active_layer - .as_ref() - .map_or(0, |(before, after)| before.len().saturating_add(after.len())), - ) - .saturating_add( - d.sketch_constraints + d.parametric_constraints .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::() .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.named_parameters.as_ref().map_or(0, |(before, after)| { + before.len().saturating_add(after.len()).saturating_mul(128) + })) .saturating_add(d.label.len()), HistorySnapshot::ObjectVisibility(v) => v .before .hidden .len() - .saturating_add( - v.before - .keep - .as_ref() - .map_or(0, rustc_hash::FxHashSet::len), - ) + .saturating_add(v.before.keep.as_ref().map_or(0, rustc_hash::FxHashSet::len)) .saturating_add(v.after.hidden.len()) - .saturating_add( - v.after - .keep - .as_ref() - .map_or(0, rustc_hash::FxHashSet::len), - ) + .saturating_add(v.after.keep.as_ref().map_or(0, rustc_hash::FxHashSet::len)) .saturating_add(v.selected_before.len()) .saturating_add(v.selected_after.len()) .saturating_mul(16) @@ -760,12 +754,12 @@ pub(super) struct DeltaSnapshot { /// Opposite non-entity document state. `apply_delta_state` swaps this with /// the live structure, so the same allocation shuttles between undo/redo. pub(super) structure: Option, - /// Sketch-constraint scopes this same command changed alongside its + /// Parametric-constraint scopes this same command changed alongside its /// entities (e.g. ERASE removing a constraint set's touched constraints) - /// — `sketch_constraints` lives on `Scene`, not in `document`/ + /// — `parametric_constraints` lives on `Scene`, not in `document`/ /// `document.objects`, so it needs its own channel here rather than /// riding along with `entities`/`structure`. Almost always empty. - pub(super) sketch_constraints: Vec, + pub(super) parametric_constraints: Vec, /// Drawing-wide parameter table changed by this transaction. pub(super) named_parameters: Option<( crate::scene::named_parameters::ParameterTable, @@ -831,13 +825,13 @@ pub(super) struct ObjectEntryDelta { pub(super) after: Option, } -/// One sketch-constraint scope's before/after image within an entity delta. +/// One parametric-constraint scope's before/after image within an entity delta. /// This keeps constraint cleanup atomic with edits such as entity erasure. #[derive(Clone)] -pub(super) struct SketchConstraintsEntryDelta { - pub(super) scope: crate::scene::sketch_constraints::SketchScope, - pub(super) before: crate::scene::sketch_constraints::SketchConstraintSet, - pub(super) after: crate::scene::sketch_constraints::SketchConstraintSet, +pub(super) struct ParametricConstraintsEntryDelta { + pub(super) scope: crate::scene::parametric_constraints::ParametricScope, + pub(super) before: crate::scene::parametric_constraints::ParametricConstraintSet, + pub(super) after: crate::scene::parametric_constraints::ParametricConstraintSet, } #[derive(Default)] diff --git a/src/app/history.rs b/src/app/history.rs index a43d387e0..421c495d0 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -1,7 +1,8 @@ use super::{ document::{ DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot, - PendingHistorySnapshot, SketchConstraintsEntryDelta, StructureSnapshot, TableEntryDelta, + ParametricConstraintsEntryDelta, PendingHistorySnapshot, StructureSnapshot, + TableEntryDelta, }, OpenCADStudio, }; @@ -167,8 +168,7 @@ impl OpenCADStudio { ) { self.finish_pending_history(i); let after = self.tabs[i].scene.object_isolation.clone(); - let selected_after: Vec = - self.tabs[i].scene.selected.iter().copied().collect(); + let selected_after: Vec = self.tabs[i].scene.selected.iter().copied().collect(); if before == after && selected_before == selected_after { return; } @@ -225,7 +225,7 @@ impl OpenCADStudio { dirty_after: true, active_layer: None, structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)), - sketch_constraints: Vec::new(), + parametric_constraints: Vec::new(), named_parameters: None, label: label.into(), }; @@ -233,9 +233,7 @@ impl OpenCADStudio { } pub(super) fn defer_live_entity_history_after(&mut self, i: usize, handle: Handle) { - let Some(HistorySnapshot::Delta(delta)) = - self.tabs[i].history.undo_stack.last_mut() - else { + let Some(HistorySnapshot::Delta(delta)) = self.tabs[i].history.undo_stack.last_mut() else { return; }; if let Some((_, _, after)) = delta @@ -254,9 +252,7 @@ impl OpenCADStudio { let selected_after = self.tabs[i].scene.selected.iter().copied().collect(); let current_layout_after = self.tabs[i].scene.current_layout.clone(); let dirty_after = self.tabs[i].dirty; - let Some(HistorySnapshot::Delta(delta)) = - self.tabs[i].history.undo_stack.last_mut() - else { + let Some(HistorySnapshot::Delta(delta)) = self.tabs[i].history.undo_stack.last_mut() else { return; }; let Some((_, _, entry_after)) = delta @@ -335,8 +331,9 @@ impl OpenCADStudio { dirty_after, active_layer: (pending.active_layer != active_layer_after) .then_some((pending.active_layer, active_layer_after)), - structure: structure_changed.then_some(StructureSnapshot::Full(pending.structure_before)), - sketch_constraints: Vec::new(), + structure: structure_changed + .then_some(StructureSnapshot::Full(pending.structure_before)), + parametric_constraints: Vec::new(), named_parameters: None, label: pending.label, }; @@ -445,7 +442,7 @@ impl OpenCADStudio { dirty_after: self.tabs[i].dirty, active_layer: None, structure: Some(StructureSnapshot::Layers(entries)), - sketch_constraints: Vec::new(), + parametric_constraints: Vec::new(), named_parameters: None, label: pending.label, }; @@ -502,7 +499,7 @@ impl OpenCADStudio { dirty_after: self.tabs[i].dirty, active_layer: None, structure: Some(StructureSnapshot::TextStyles(entries)), - sketch_constraints: Vec::new(), + parametric_constraints: Vec::new(), named_parameters: None, label: pending.label, }; @@ -559,7 +556,7 @@ impl OpenCADStudio { dirty_after: self.tabs[i].dirty, active_layer: None, structure: Some(StructureSnapshot::DimStyles(entries)), - sketch_constraints: Vec::new(), + parametric_constraints: Vec::new(), named_parameters: None, label: pending.label, }; @@ -624,7 +621,7 @@ impl OpenCADStudio { dirty_after: self.tabs[i].dirty, active_layer: None, structure: Some(StructureSnapshot::Objects(entries)), - sketch_constraints: Vec::new(), + parametric_constraints: Vec::new(), named_parameters: None, label: pending.label, }; @@ -659,7 +656,7 @@ impl OpenCADStudio { dim_names, object_handles, }), - sketch_constraints: Vec::new(), + parametric_constraints: Vec::new(), named_parameters: None, label: "STYLE".to_string(), }; @@ -741,15 +738,16 @@ impl OpenCADStudio { pending.label ); } - let (entity_before, object_before, sketch_constraints_before, named_parameters_before) = + let (entity_before, object_before, parametric_constraints_before, named_parameters_before) = rec.into_recorded_images(); - let entities: Vec<(Handle, Option>, Option>)> = entity_before - .into_iter() - .map(|(h, before)| { - let after = self.tabs[i].scene.document.get_entity_arc(h); - (h, before, after) - }) - .collect(); + let entities: Vec<(Handle, Option>, Option>)> = + entity_before + .into_iter() + .map(|(h, before)| { + let after = self.tabs[i].scene.document.get_entity_arc(h); + (h, before, after) + }) + .collect(); let objects: Vec = object_before .into_iter() .filter_map(|(handle, before)| { @@ -761,21 +759,28 @@ impl OpenCADStudio { }) }) .collect(); - let sketch_constraints: Vec = sketch_constraints_before - .into_iter() - .filter_map(|(scope, before)| { - let after = self.tabs[i] - .scene - .sketch_constraint_set(scope) - .cloned() - .unwrap_or_else(|| crate::scene::sketch_constraints::SketchConstraintSet::new(scope)); - (before.constraints != after.constraints).then_some(SketchConstraintsEntryDelta { - scope, - before, - after, + let parametric_constraints: Vec = + parametric_constraints_before + .into_iter() + .filter_map(|(scope, before)| { + let after = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new( + scope, + ) + }); + (before.constraints != after.constraints).then_some( + ParametricConstraintsEntryDelta { + scope, + before, + after, + }, + ) }) - }) - .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)) @@ -815,7 +820,7 @@ impl OpenCADStudio { dirty_after, active_layer: None, structure, - sketch_constraints, + parametric_constraints, named_parameters, label: pending.label, }; @@ -839,9 +844,10 @@ impl OpenCADStudio { if let Some(structure) = d.structure.as_mut() { match structure { StructureSnapshot::Full(stored) => { - let inverse = self.tabs[i].scene.document.swap_structure( - std::mem::replace(stored, acadrust::CadDocument::new()), - ); + let inverse = self.tabs[i] + .scene + .document + .swap_structure(std::mem::replace(stored, acadrust::CadDocument::new())); *stored = inverse; self.tabs[i].scene.invalidate_dependency_index(); } @@ -945,9 +951,9 @@ impl OpenCADStudio { } let changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo); let scene = &mut self.tabs[i].scene; - for entry in &d.sketch_constraints { + for entry in &d.parametric_constraints { let value = if undo { &entry.before } else { &entry.after }; - *scene.sketch_constraint_set_mut(entry.scope) = value.clone(); + *scene.parametric_constraint_set_mut(entry.scope) = value.clone(); } if let Some((before, after)) = &d.named_parameters { scene.named_parameters = if undo { before } else { after }.clone(); @@ -1070,6 +1076,7 @@ impl OpenCADStudio { } } } + scene.sync_native_parametric_graph(); scene.clear_preview_wire(); } // UCS state is cached separately from its persisted header/viewport @@ -1104,7 +1111,8 @@ impl OpenCADStudio { let available = self.tabs[i].history.undo_stack.len(); let steps = steps.min(available); if steps == 0 { - self.command_line.push_info(crate::t!("Nothing to undo.").as_ref()); + self.command_line + .push_info(crate::t!("Nothing to undo.").as_ref()); return; } @@ -1127,14 +1135,15 @@ impl OpenCADStudio { structure, StructureSnapshot::Full(_) | StructureSnapshot::Layers(_) ) - }) || d.active_layer.is_some() || d.entities.iter().any(|(_, before, after)| { - before - .as_deref() - .is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) - || after + }) || d.active_layer.is_some() + || d.entities.iter().any(|(_, before, after)| { + before .as_deref() .is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) - }); + || after + .as_deref() + .is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) + }); had_full |= d.structure.as_ref().is_some_and(StructureSnapshot::is_full); changes.extend(self.apply_delta_state(i, &mut d, true)); self.tabs[i] @@ -1162,7 +1171,8 @@ impl OpenCADStudio { let available = self.tabs[i].history.redo_stack.len(); let steps = steps.min(available); if steps == 0 { - self.command_line.push_info(crate::t!("Nothing to redo.").as_ref()); + self.command_line + .push_info(crate::t!("Nothing to redo.").as_ref()); return; } @@ -1182,14 +1192,15 @@ impl OpenCADStudio { structure, StructureSnapshot::Full(_) | StructureSnapshot::Layers(_) ) - }) || d.active_layer.is_some() || d.entities.iter().any(|(_, before, after)| { - before - .as_deref() - .is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) - || after + }) || d.active_layer.is_some() + || d.entities.iter().any(|(_, before, after)| { + before .as_deref() .is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) - }); + || after + .as_deref() + .is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) + }); had_full |= d.structure.as_ref().is_some_and(StructureSnapshot::is_full); changes.extend(self.apply_delta_state(i, &mut d, false)); self.tabs[i] diff --git a/src/app/mod.rs b/src/app/mod.rs index 0ad215b09..50a864cf0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -7,6 +7,7 @@ pub(crate) fn automation_action_names() -> &'static [&'static str] { pub(crate) mod config; #[cfg(not(target_arch = "wasm32"))] pub use automation::{export_headless, serve}; +mod annotation_data; mod command_driver; pub(crate) mod commands; mod document; @@ -30,7 +31,6 @@ mod startup; mod style_ops; mod text_inline; mod tolerance_dialog; -mod annotation_data; mod update; mod view; mod visibility; @@ -260,12 +260,12 @@ pub(crate) enum FindMatchKey { } use crate::snap::Snapper; use crate::ui::{CommandLine, Ribbon, StatusBar}; -use acadrust::CadDocument; use acadrust::types::{Color as AcadColor, LineWeight}; +use acadrust::CadDocument; use iced::time::Instant; use iced::window; -use iced::{Point, Task, Theme, mouse}; +use iced::{mouse, Point, Task, Theme}; use std::sync::Arc; pub(super) const POLY_START_DELAY_MS: u128 = 150; @@ -552,11 +552,7 @@ pub(super) struct OpenCADStudio { /// When true (default), the app registers itself as a .dwg/.dxf/.bak file /// handler on each launch. Toggle with the FILEASSOC command. pub file_assoc_enabled: bool, - /// When true, saving creates native constraint objects alongside the - /// application's own persistence record. Existing native objects remain - /// synchronized regardless of this setting. - pub write_dwg_native_constraints: bool, - /// When true (default), a sketch constraint's viewport pill shows its + /// When true (default), a parametric constraint's viewport pill shows its /// glyph plus a driven value or named-parameter name. When false, every /// pill shows only the glyph. pub show_constraint_values: bool, @@ -2118,8 +2114,6 @@ pub enum Message { /// Register or unregister as the .dwg/.dxf handler, from Options. Same /// setting the FILEASSOC command carries. FileAssocChanged(bool), - /// Toggle writing native constraint objects on save. - WriteDwgNativeConstraintsChanged(bool), /// Toggle showing driven values/named-parameter names on constraint /// pills, from Options. See `show_constraint_values`'s doc comment. ShowConstraintValuesChanged(bool), @@ -2547,9 +2541,9 @@ pub enum Message { /// Cycle the coordinate readout mode ($COORDS): static → live → polar. CycleCoordsMode, /// Removes one flagged redundant or conflicting constraint from the - /// current sketch scope. + /// current parametric scope. /// No-op if the scope currently has no flagged conflict. - ResolveOneSketchConflict, + ResolveOneParametricConflict, /// Toggle the status-bar customization menu open/closed. ToggleStatusBarMenu, /// Close the status-bar customization menu. @@ -3636,7 +3630,6 @@ impl OpenCADStudio { dimension_continue_mode: 1, backup_on_save: true, file_assoc_enabled: true, - write_dwg_native_constraints: false, show_constraint_values: true, savetime_min: 10, default_bg_color: None, diff --git a/src/app/properties.rs b/src/app/properties.rs index 7096e7b03..393467b2d 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1,10 +1,10 @@ use super::helpers::{entity_type_key, entity_type_label, title_case_word}; use super::{OpenCADStudio, VARIES_LABEL}; use crate::io::linetypes; -use crate::scene::view::dispatch; use crate::scene::model::object::PropValue; -use crate::ui; +use crate::scene::view::dispatch; use crate::t; +use crate::ui; use acadrust::types::{Transform, Vector3}; use acadrust::{Entity, EntityType, Handle}; @@ -37,9 +37,7 @@ impl OpenCADStudio { // building the panel, and syncing the ribbon. let t_all = crate::perf::enabled().then(iced::time::Instant::now); let i = self.active_tab; - if !crate::entities::object_data::cache_is_prepared( - &self.tabs[i].scene.object_data_cache, - ) { + if !crate::entities::object_data::cache_is_prepared(&self.tabs[i].scene.object_data_cache) { self.tabs[i].scene.object_data_cache = crate::entities::object_data::build_cache(&self.tabs[i].scene.document); } @@ -153,7 +151,9 @@ impl OpenCADStudio { } _ => None, }); - vertex_count.map_or(prop_vertex, |count| prop_vertex.min(count.saturating_sub(1))) + vertex_count.map_or(prop_vertex, |count| { + prop_vertex.min(count.saturating_sub(1)) + }) } else { prop_vertex }; @@ -202,9 +202,7 @@ impl OpenCADStudio { } else if !header.current_linetype_handle.is_null() { doc.line_types .iter() - .find(|line_type| { - line_type.handle == header.current_linetype_handle - }) + .find(|line_type| line_type.handle == header.current_linetype_handle) .map(|line_type| line_type.name.clone()) .unwrap_or_else(|| "ByLayer".to_string()) } else { @@ -308,7 +306,10 @@ impl OpenCADStudio { PropSection { title: t!("General").into_owned(), props: vec![ - read_only(t!("AC Version").as_ref(), doc.version.as_str().to_string()), + read_only( + t!("AC Version").as_ref(), + doc.version.as_str().to_string(), + ), Property { label: t!("Color").into_owned(), field: "color", @@ -341,11 +342,20 @@ impl OpenCADStudio { label: t!("Transparency").into_owned(), field: "transparency", value: PropValue::EditChoice { - value: crate::scene::creation_style::current_transparency_label(self.tabs[i].scene.document.current_entity_transparency()), + value: + crate::scene::creation_style::current_transparency_label( + self.tabs[i] + .scene + .document + .current_entity_transparency(), + ), options: vec!["ByLayer".to_string(), "ByBlock".to_string()], }, }, - read_only(t!("Thickness").as_ref(), format_length(header.thickness)), + read_only( + t!("Thickness").as_ref(), + format_length(header.thickness), + ), ], }, PropSection { @@ -440,7 +450,8 @@ impl OpenCADStudio { label: t!("UCS icon On").into_owned(), field: "view_ucs_icon_on", value: PropValue::Choice { - selected: if ucs_icon_on { "Yes" } else { "No" }.to_string(), + selected: if ucs_icon_on { "Yes" } else { "No" } + .to_string(), options: vec!["Yes".to_string(), "No".to_string()], }, }, @@ -448,7 +459,8 @@ impl OpenCADStudio { label: t!("UCS icon at origin").into_owned(), field: "view_ucs_icon_at_origin", value: PropValue::Choice { - selected: if ucs_icon_at_origin { "Yes" } else { "No" }.to_string(), + selected: if ucs_icon_at_origin { "Yes" } else { "No" } + .to_string(), options: vec!["Yes".to_string(), "No".to_string()], }, }, @@ -456,7 +468,8 @@ impl OpenCADStudio { label: t!("UCS per viewport").into_owned(), field: "view_ucs_per_viewport", value: PropValue::Choice { - selected: if ucs_per_viewport { "Yes" } else { "No" }.to_string(), + selected: if ucs_per_viewport { "Yes" } else { "No" } + .to_string(), options: vec!["Yes".to_string(), "No".to_string()], }, }, @@ -478,7 +491,9 @@ impl OpenCADStudio { .iter() .flat_map(|section| section.props.iter()) .filter_map(|prop| match &prop.value { - crate::scene::model::object::PropValue::Choice { options, .. } => Some(( + crate::scene::model::object::PropValue::Choice { + options, .. + } => Some(( prop.field.to_string(), iced::widget::combo_box::State::new( options @@ -493,9 +508,7 @@ impl OpenCADStudio { .collect(), sections, layer_combo: iced::widget::combo_box::State::new(layer_names.clone()), - linetype_combo: iced::widget::combo_box::State::new( - linetype_items.clone(), - ), + linetype_combo: iced::widget::combo_box::State::new(linetype_items.clone()), lineweight_combo: iced::widget::combo_box::State::new( ui::properties::lw_options(), ), @@ -515,7 +528,8 @@ impl OpenCADStudio { } else { crate::command::WorkingPlane::default() }; - let display_entity = dispatch::entity_in_working_plane(contextual.as_ref(), plane); + let display_entity = + dispatch::entity_in_working_plane(contextual.as_ref(), plane); let entity = &display_entity; let group_names = self.tabs[i].scene.group_names_for_entity(handle); let compact_solid = @@ -528,12 +542,10 @@ impl OpenCADStudio { if compact_solid { retain_compact_solid_sections(&mut sections); } - sections.extend( - crate::scene::model::solid_history::primitive_properties( - &self.tabs[i].scene.document, - handle, - ), - ); + sections.extend(crate::scene::model::solid_history::primitive_properties( + &self.tabs[i].scene.document, + handle, + )); // Turn the Material row into an editable picker: the source // options (ByLayer / ByBlock) plus every named material the @@ -606,10 +618,11 @@ impl OpenCADStudio { if let Some(row) = section.props.iter_mut().find(|row| row.field == "color") { - row.value = crate::scene::model::object::PropValue::NamedColorChoice { - color, - name: color_name.clone(), - }; + row.value = + crate::scene::model::object::PropValue::NamedColorChoice { + color, + name: color_name.clone(), + }; } } use crate::entities::common::ro_prop; @@ -788,7 +801,12 @@ impl OpenCADStudio { metrics .principal_directions .chunks_exact(3) - .map(|axis| format!("({:.6}, {:.6}, {:.6})", axis[0], axis[1], axis[2])) + .map(|axis| { + format!( + "({:.6}, {:.6}, {:.6})", + axis[0], axis[1], axis[2] + ) + }) .collect::>() .join(", "), ), @@ -870,11 +888,9 @@ impl OpenCADStudio { if let acadrust::EntityType::Insert(ins) = entity { let eq = (ins.x_scale() - ins.y_scale()).abs() < 1e-12 && (ins.x_scale() - ins.z_scale()).abs() < 1e-12; - let uniform = - eq && !self.props_asym_scale.contains(&handle.value()); + let uniform = eq && !self.props_asym_scale.contains(&handle.value()); for section in sections.iter_mut() { - let Some(xi) = - section.props.iter().position(|p| p.field == "x_scale") + let Some(xi) = section.props.iter().position(|p| p.field == "x_scale") else { continue; }; @@ -930,12 +946,14 @@ impl OpenCADStudio { _ => common .plotstyle_handle .and_then(|ph| { - crate::scene::annotative::as_dict(doc, dict_h).and_then(|dict| { - dict.entries - .iter() - .find(|(_, h)| *h == ph) - .map(|(n, _)| n.clone()) - }) + crate::scene::annotative::as_dict(doc, dict_h).and_then( + |dict| { + dict.entries + .iter() + .find(|(_, h)| *h == ph) + .map(|(n, _)| n.clone()) + }, + ) }) .unwrap_or_else(|| "ByLayer".to_string()), }; @@ -1025,19 +1043,28 @@ impl OpenCADStudio { let cur_tstyle = ml .text_style_handle .and_then(|h| { - doc.text_styles.iter().find(|s| s.handle == h).map(|s| s.name.clone()) + doc.text_styles + .iter() + .find(|s| s.handle == h) + .map(|s| s.name.clone()) }) .unwrap_or_else(|| "Standard".to_string()); let cur_arrow = ml .arrowhead_handle .and_then(|h| { - doc.block_records.iter().find(|b| b.handle == h).map(|b| b.name.clone()) + doc.block_records + .iter() + .find(|b| b.handle == h) + .map(|b| b.name.clone()) }) .unwrap_or_else(|| "Closed filled".to_string()); let cur_ltype = ml .line_type_handle .and_then(|h| { - doc.line_types.iter().find(|l| l.handle == h).map(|l| l.name.clone()) + doc.line_types + .iter() + .find(|l| l.handle == h) + .map(|l| l.name.clone()) }) .unwrap_or_else(|| "ByBlock".to_string()); let cur_block = ml @@ -1231,7 +1258,9 @@ impl OpenCADStudio { .find(|property| property.field == "style_name") { let current = match &prop.value { - crate::scene::model::object::PropValue::PlainText(s) => s.clone(), + crate::scene::model::object::PropValue::PlainText(s) => { + s.clone() + } _ => String::new(), }; prop.value = crate::scene::model::object::PropValue::Choice { @@ -1262,7 +1291,9 @@ impl OpenCADStudio { use crate::entities::dim_override as dov; use crate::scene::model::object::PropValue; let dim_c = dov::color(&d.base().common.extended_data, dov::DIMCLRD) - .unwrap_or_else(|| acadrust::types::Color::from_index(style.dimclrd)); + .unwrap_or_else(|| { + acadrust::types::Color::from_index(style.dimclrd) + }); set_row_value( &mut sections, "dim_line_color", @@ -1276,11 +1307,7 @@ impl OpenCADStudio { .unwrap_or_else(|| { acadrust::types::Color::from_index(inherited) }); - set_row_value( - &mut sections, - field, - PropValue::ColorChoice(color), - ); + set_row_value(&mut sections, field, PropValue::ColorChoice(color)); } } } @@ -1311,9 +1338,7 @@ impl OpenCADStudio { // insert of it follows). Anonymous (*) and // xref(-dependent) blocks keep the read-only row. let regular = |br: &acadrust::tables::BlockRecord| { - !br.is_anonymous() - && !br.flags.is_xref - && !br.name.contains('|') + !br.is_anonymous() && !br.flags.is_xref && !br.name.contains('|') }; let editable = doc .block_records @@ -1327,9 +1352,7 @@ impl OpenCADStudio { .filter(|br| regular(br)) .map(|br| br.name.clone()) .collect(); - options.sort_by(|a, b| { - a.to_lowercase().cmp(&b.to_lowercase()) - }); + options.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); set_row_value( &mut sections, "block", @@ -1379,9 +1402,9 @@ impl OpenCADStudio { .find(|p| p.field == "dimension_style") { let cur = match &p.value { - crate::scene::model::object::PropValue::PlainText(s) => { - s.clone() - } + crate::scene::model::object::PropValue::PlainText( + s, + ) => s.clone(), _ => ld.dimension_style.clone(), }; p.value = crate::scene::model::object::PropValue::Choice { @@ -1456,8 +1479,9 @@ impl OpenCADStudio { ); // A per-object color override wins over the style. - let dim_c = dov::color(xd, dov::DIMCLRD) - .unwrap_or_else(|| acadrust::types::Color::from_index(ds.dimclrd)); + let dim_c = dov::color(xd, dov::DIMCLRD).unwrap_or_else(|| { + acadrust::types::Color::from_index(ds.dimclrd) + }); set_row_value( &mut sections, "dim_line_color", @@ -1494,9 +1518,8 @@ impl OpenCADStudio { acadrust::EntityType::Tolerance(tol) => { use crate::entities::dim_override as dov; let style = crate::entities::tolerance::resolve_dim_style(tol, doc); - let style_name = style - .map(|entry| entry.name.clone()) - .unwrap_or_else(|| { + let style_name = + style.map(|entry| entry.name.clone()).unwrap_or_else(|| { if tol.dimension_style_name.trim().is_empty() { "Standard".to_string() } else { @@ -1524,24 +1547,18 @@ impl OpenCADStudio { }, ); - let text_style_name = dov::handle( - &tol.common.extended_data, - dov::DIMTXSTY, - ) - .and_then(|handle| { - doc.text_styles - .iter() - .find(|entry| entry.handle == handle) - }) - .map(|entry| entry.name.clone()) - .or_else(|| style.map(|entry| entry.dimtxsty.clone())) - .unwrap_or_else(|| "Standard".to_string()); + let text_style_name = + dov::handle(&tol.common.extended_data, dov::DIMTXSTY) + .and_then(|handle| { + doc.text_styles.iter().find(|entry| entry.handle == handle) + }) + .map(|entry| entry.name.clone()) + .or_else(|| style.map(|entry| entry.dimtxsty.clone())) + .unwrap_or_else(|| "Standard".to_string()); let text_height_editable = doc .text_styles .iter() - .find(|entry| { - entry.name.eq_ignore_ascii_case(&text_style_name) - }) + .find(|entry| entry.name.eq_ignore_ascii_case(&text_style_name)) .is_none_or(|entry| !entry.has_fixed_height()); let mut names = text_style_names.clone(); if !names @@ -1559,12 +1576,9 @@ impl OpenCADStudio { }, ); - let height = dov::real( - &tol.common.extended_data, - dov::DIMTXT, - ) - .or_else(|| style.map(|entry| entry.dimtxt)) - .unwrap_or(tol.text_height); + let height = dov::real(&tol.common.extended_data, dov::DIMTXT) + .or_else(|| style.map(|entry| entry.dimtxt)) + .unwrap_or(tol.text_height); set_row_value( &mut sections, "tol_text_height", @@ -1588,9 +1602,9 @@ impl OpenCADStudio { set_row_value( &mut sections, "text_height", - PropValue::ReadOnly( - crate::entities::common::format_length(ml.text_height), - ), + PropValue::ReadOnly(crate::entities::common::format_length( + ml.text_height, + )), ); } if let Some(sh) = ml.style_handle { @@ -1645,15 +1659,16 @@ impl OpenCADStudio { .table_style_handle .and_then(|handle| doc.objects.get(&handle)) .and_then(|object| match object { - acadrust::objects::ObjectType::TableStyle(style) => { - Some(style) - } + acadrust::objects::ObjectType::TableStyle(style) => Some(style), _ => None, }); let selected = selected_style .map(|style| style.name.clone()) .unwrap_or_else(|| "Standard".to_string()); - if !names.iter().any(|name| name.eq_ignore_ascii_case(&selected)) { + if !names + .iter() + .any(|name| name.eq_ignore_ascii_case(&selected)) + { names.insert(0, selected.clone()); } set_row_value( @@ -1675,10 +1690,8 @@ impl OpenCADStudio { table, selected_style, ); - let flow_up = crate::entities::table::resolved_flow_up( - table, - selected_style, - ); + let flow_up = + crate::entities::table::resolved_flow_up(table, selected_style); let (horizontal_margin, vertical_margin) = crate::entities::table::resolved_table_margins( table, @@ -1713,9 +1726,7 @@ impl OpenCADStudio { let columns = table.column_count(); if columns > 0 { let cell_index = prop_vertex.min( - table.row_count() - .saturating_mul(columns) - .saturating_sub(1), + table.row_count().saturating_mul(columns).saturating_sub(1), ); let row_index = cell_index / columns; let column_index = cell_index % columns; @@ -1796,14 +1807,13 @@ impl OpenCADStudio { "tbl_cell_text_height", crate::entities::common::format_length(text_height), ); - let content_color = local( - CellStylePropertyFlags::CONTENT_COLOR, - ) - .map(|style| style.content_color) - .or_else(|| { - document_row_style.map(|style| style.text_color) - }) - .unwrap_or(acadrust::types::Color::ByBlock); + let content_color = + local(CellStylePropertyFlags::CONTENT_COLOR) + .map(|style| style.content_color) + .or_else(|| { + document_row_style.map(|style| style.text_color) + }) + .unwrap_or(acadrust::types::Color::ByBlock); update_row_color( &mut sections, "tbl_cell_content_color", @@ -1828,8 +1838,7 @@ impl OpenCADStudio { background_style .map(|style| style.fill_enabled) .or_else(|| { - document_row_style - .map(|style| style.fill_enabled) + document_row_style.map(|style| style.fill_enabled) }) .unwrap_or(false), ); @@ -1840,23 +1849,18 @@ impl OpenCADStudio { .map(|style| style.format_string.clone()) }) .unwrap_or_default(); - update_row_text( - &mut sections, - "tbl_cell_format", - format, - ); + update_row_text(&mut sections, "tbl_cell_format", format); let data_type = cell .contents .first() .map(|content| content.value.value_type) .filter(|kind| *kind != CellValueType::Unknown) .or_else(|| { - local(CellStylePropertyFlags::DATA_TYPE) - .map(|style| { - CellValueType::from( - style.value_data_type.max(0) as u32, - ) - }) + local(CellStylePropertyFlags::DATA_TYPE).map(|style| { + CellValueType::from( + style.value_data_type.max(0) as u32 + ) + }) }) .or_else(|| { document_row_style.map(|style| { @@ -1927,9 +1931,7 @@ impl OpenCADStudio { ] .into_iter() .flatten() - .find(|style| { - style.applied_border_edges.contains(edge) - }); + .find(|style| style.applied_border_edges.contains(edge)); if let Some(style) = local_style { return if edge == CellEdgeFlags::TOP { !style.top_border.invisible @@ -2006,12 +2008,10 @@ impl OpenCADStudio { || match entity { acadrust::EntityType::Dimension( acadrust::entities::Dimension::Arc(dimension), - ) => { - crate::scene::annotative::dim_style_is_annotative( - doc, - &dimension.base.style_name, - ) - } + ) => crate::scene::annotative::dim_style_is_annotative( + doc, + &dimension.base.style_name, + ), acadrust::EntityType::Tolerance(_) => { crate::scene::annotative::annotation_style_is_annotative( doc, entity, @@ -2087,10 +2087,11 @@ impl OpenCADStudio { } } if is_anno { - let memberships = crate::scene::annotative::object_scale_memberships( - doc, - entity.common().handle, - ); + let memberships = + crate::scene::annotative::object_scale_memberships( + doc, + entity.common().handle, + ); let assigned_scales = if memberships.is_empty() { doc.header.current_annotation_scale.clone() } else { @@ -2189,7 +2190,7 @@ impl OpenCADStudio { if compact_solid { retain_compact_solid_sections(&mut sections); } - // Persistent sketch-constraint list for this entity — + // Parametric-constraint list for this entity — // the Properties-panel analogue of the viewport's // constraint glyph pills: one row per constraint // touching `handle`, clickable to select every entity @@ -2198,33 +2199,49 @@ impl OpenCADStudio { // entity (the common case) shouldn't gain empty // bookkeeping. { - let scope = self.tabs[i].current_sketch_scope(); + let scope = self.tabs[i].current_parametric_scope(); let scene = &self.tabs[i].scene; - if let Some(set) = scene.sketch_constraint_set(scope) { + if let Some(set) = scene.parametric_constraint_set(scope) { let props: Vec = set .constraints_touching(handle) .map(|c| { - let conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id); + let conflicting = + set.conflicts.iter().any(|(id, _)| *id == c.id); let value_suffix = match &c.driving_param { - Some(crate::scene::named_parameters::DrivingValue::Literal(v)) => { + Some( + crate::scene::named_parameters::DrivingValue::Literal( + v, + ), + ) => { format!(" = {v:.2}") } - Some(crate::scene::named_parameters::DrivingValue::Named(name)) => { - match scene.named_parameters().resolve(name) { - Ok(v) => format!(" = {name} ({v:.2})"), - Err(_) => format!(" = {name} (?)"), - } - } + Some( + crate::scene::named_parameters::DrivingValue::Named( + name, + ), + ) => match scene.named_parameters().resolve(name) { + Ok(v) => format!(" = {name} ({v:.2})"), + Err(_) => format!(" = {name} (?)"), + }, None => String::new(), }; - let label = format!("{} {:?}{}", c.kind.glyph_symbol(), c.kind, value_suffix); - let mut handles: Vec = c.refs.iter().map(|r| r.entity).collect(); + let label = format!( + "{} {:?}{}", + c.kind.glyph_symbol(), + c.kind, + value_suffix + ); + let mut handles: Vec = + c.refs.iter().map(|r| r.entity).collect(); handles.sort_by_key(|h| h.value()); handles.dedup(); crate::scene::model::object::Property { label, - field: "sketch_constraint", - value: crate::scene::model::object::PropValue::EntityLink { handles, conflicting }, + field: "parametric_constraint", + value: crate::scene::model::object::PropValue::EntityLink { + handles, + conflicting, + }, } }) .collect(); @@ -2270,7 +2287,9 @@ impl OpenCADStudio { .iter() .flat_map(|section| section.props.iter()) .filter_map(|prop| match &prop.value { - crate::scene::model::object::PropValue::Choice { options, .. } => Some(( + crate::scene::model::object::PropValue::Choice { + options, .. + } => Some(( prop.field.to_string(), iced::widget::combo_box::State::new( options @@ -2335,20 +2354,19 @@ impl OpenCADStudio { } else { crate::command::WorkingPlane::default() }; - let local_entities: Vec<(Handle, std::borrow::Cow<'_, EntityType>)> = - filtered - .iter() - .map(|(handle, entity)| { - let local = if plane.is_identity() { - std::borrow::Cow::Borrowed(*entity) - } else { - std::borrow::Cow::Owned( - dispatch::entity_in_working_plane(entity, plane), - ) - }; - (*handle, local) - }) - .collect(); + let local_entities: Vec<(Handle, std::borrow::Cow<'_, EntityType>)> = filtered + .iter() + .map(|(handle, entity)| { + let local = if plane.is_identity() { + std::borrow::Cow::Borrowed(*entity) + } else { + std::borrow::Cow::Owned(dispatch::entity_in_working_plane( + entity, plane, + )) + }; + (*handle, local) + }) + .collect(); let local_refs: Vec<(Handle, &EntityType)> = local_entities .iter() .map(|(handle, entity)| (*handle, entity.as_ref())) @@ -2381,7 +2399,10 @@ filter={:.1} local={:.1} aggregate={:.1} entities={}", } sections.extend(aggregate_solid_history_sections( &self.tabs[i].scene.document, - &local_refs.iter().map(|(handle, _)| *handle).collect::>(), + &local_refs + .iter() + .map(|(handle, _)| *handle) + .collect::>(), )); if compact_solids { retain_compact_solid_sections(&mut sections); @@ -2391,7 +2412,9 @@ filter={:.1} local={:.1} aggregate={:.1} entities={}", .iter() .flat_map(|section| section.props.iter()) .filter_map(|prop| match &prop.value { - crate::scene::model::object::PropValue::Choice { options, .. } => Some(( + crate::scene::model::object::PropValue::Choice { + options, .. + } => Some(( prop.field.to_string(), iced::widget::combo_box::State::new( options @@ -2458,8 +2481,7 @@ filter={:.1} local={:.1} aggregate={:.1} entities={}", make_sections_read_only(&mut panel.sections); // Rows demoted to read-only no longer back an editable field; // drop them from the id→key map so focus can't map onto them. - panel.field_key_by_id = - crate::ui::properties::build_field_key_map(&panel.sections); + panel.field_key_by_id = crate::ui::properties::build_field_key_map(&panel.sections); panel.edit_buf.clear(); panel.active_field = None; panel.color_picker_open = false; @@ -2624,17 +2646,13 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} let is_paper = self.tabs[i].scene.current_layout != "Model"; // Paper-space entity coordinates are NOT offset by world_offset (same rule // as wire tessellation in wires_for_block). Only subtract in model space. - let wo = if is_paper { - [0.0f64; 3] - } else { - [0.0_f64; 3] - }; + let wo = if is_paper { [0.0f64; 3] } else { [0.0_f64; 3] }; let (new_handle, new_grips, new_grip_handles) = { let annotation_scale_handle = self.tabs[i].scene.displayed_annotation_scale_handle(); let selected = self.tabs[i].scene.selected_entities(); let single_handle = (selected.len() == 1 && !self.tabs[i].scene.is_layer_locked(selected[0].0)) - .then(|| selected[0].0); + .then(|| selected[0].0); let mut grips = Vec::new(); let mut handles = Vec::new(); // Avoid rebuilding every entity's grip markers above the configured @@ -2729,9 +2747,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} .copied() .zip(self.tabs[i].selected_grips.iter().map(|grip| grip.id)) .collect(); - self.tabs[i] - .hot_grips - .retain(|key| available.contains(key)); + self.tabs[i].hot_grips.retain(|key| available.contains(key)); // Append the dynamic-block visibility (lookup) grip, if the lone // selection is a visibility-parametric block reference. self.refresh_visibility_grip(wo); @@ -2768,9 +2784,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} ) { self.tabs[i].scene.invalidate_dim_block_recorded(handle); } - context_object_changed |= self.tabs[i] - .scene - .sync_displayed_annotation_context(handle); + context_object_changed |= self.tabs[i].scene.sync_displayed_annotation_context(handle); // Hatch / SOLID fills render from prebuilt cached models; rebuild // them or pattern edits (scale, background, …) stay invisible // (#415). @@ -2826,10 +2840,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} /// Like [`commit_entity`] but returns the handle the new entity was given /// (or `None` if it could not be added). Lets callers follow up — e.g. /// open the in-place text editor on a freshly created MultiLeader. - pub(super) fn commit_entity_handle( - &mut self, - entity: acadrust::EntityType, - ) -> Option { + pub(super) fn commit_entity_handle(&mut self, entity: acadrust::EntityType) -> Option { self.commit_entity_handle_with_policies(entity, false, false, false) } @@ -2893,9 +2904,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} const PREFIX: &str = "__OPENCAD_LINK_PENDING__"; if let Some(path) = table.name.strip_prefix(PREFIX).map(str::to_string) { use acadrust::entities::table::CellStateFlags; - use acadrust::objects::{ - ClassObject, ClassObjectData, DataLink, ObjectType, - }; + use acadrust::objects::{ClassObject, ClassObjectData, DataLink, ObjectType}; let document = &mut self.tabs[i].scene.document; let link_handle = document.allocate_handle(); let mut object = ClassObject::new(ClassObjectData::DataLink(DataLink { @@ -2997,7 +3006,11 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} // CELTSCALE (header.current_entity_linetype_scale): new entities // pick up the document's saved per-entity linetype scale. The user // can override per entity later via the properties panel. - let celtscale = self.tabs[i].scene.document.header.current_entity_linetype_scale; + let celtscale = self.tabs[i] + .scene + .document + .header + .current_entity_linetype_scale; if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 { entity.common_mut().linetype_scale = celtscale; } @@ -3019,12 +3032,12 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} // Smart centre objects carry their own drawing-level creation style. // Apply it after the generic ribbon style so ordinary LINE entities // keep the existing path while centre lines honour their settings. - let center_line = acadrust::entities::CenterLineAssociation::read( - &entity.common().extended_data, - ).is_some(); - let center_mark = acadrust::entities::CenterMarkAssociation::read( - &entity.common().extended_data, - ).is_some(); + let center_line = + acadrust::entities::CenterLineAssociation::read(&entity.common().extended_data) + .is_some(); + let center_mark = + acadrust::entities::CenterMarkAssociation::read(&entity.common().extended_data) + .is_some(); if center_line || center_mark { let settings = self.tabs[i].scene.centerline_settings(); if !settings.layer.eq_ignore_ascii_case("Current") { @@ -3039,12 +3052,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} } else { acadrust::entities::CENTERLINE_XDATA_APPLICATION }; - if !self.tabs[i] - .scene - .document - .app_ids - .contains(application) - { + if !self.tabs[i].scene.document.app_ids.contains(application) { let mut app = acadrust::tables::AppId::new(application); app.handle = self.tabs[i].scene.document.allocate_handle(); let _ = self.tabs[i].scene.document.app_ids.add(app); @@ -3052,12 +3060,10 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} } let text_style_annotative = match &entity { - acadrust::EntityType::Text(text) => { - crate::scene::annotative::text_style_is_annotative( - &self.tabs[i].scene.document, - &text.style, - ) - } + acadrust::EntityType::Text(text) => crate::scene::annotative::text_style_is_annotative( + &self.tabs[i].scene.document, + &text.style, + ), acadrust::EntityType::MText(text) => { crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, @@ -3090,13 +3096,12 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} _ => {} } } - let needs_annotation_context = crate::scene::annotative::is_annotative( - &self.tabs[i].scene.document, - &entity, - ) || crate::scene::annotative::annotation_style_is_annotative( - &self.tabs[i].scene.document, - &entity, - ); + let needs_annotation_context = + crate::scene::annotative::is_annotative(&self.tabs[i].scene.document, &entity) + || crate::scene::annotative::annotation_style_is_annotative( + &self.tabs[i].scene.document, + &entity, + ); let new_handle = if matches!(&entity, acadrust::EntityType::Viewport(_)) && self.tabs[i].scene.current_layout != "Model" @@ -3181,9 +3186,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} } } -fn make_sections_read_only( - sections: &mut [crate::scene::model::object::PropSection], -) { +fn make_sections_read_only(sections: &mut [crate::scene::model::object::PropSection]) { use crate::scene::model::object::PropValue; for property in sections @@ -3208,15 +3211,13 @@ fn make_sections_read_only( acadrust::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"), }, PropValue::NamedColorChoice { name, .. } => name.clone(), - PropValue::ColorVaries - | PropValue::LwVaries - | PropValue::FieldLwVaries { .. } => VARIES_LABEL.to_string(), + PropValue::ColorVaries | PropValue::LwVaries | PropValue::FieldLwVaries { .. } => { + VARIES_LABEL.to_string() + } PropValue::LwChoice(lineweight) | PropValue::FieldLwChoice { value: lineweight, .. - } => { - ui::properties::LwItem(*lineweight).to_string() - } + } => ui::properties::LwItem(*lineweight).to_string(), PropValue::BoolToggle { value, .. } => { if *value { t!("Yes") } else { t!("No") }.into_owned() } @@ -3312,9 +3313,9 @@ fn aggregate_solid_history_sections( document: &acadrust::CadDocument, handles: &[Handle], ) -> Vec { - let mut sections = handles.iter().map(|handle| { - crate::scene::model::solid_history::primitive_properties(document, *handle) - }); + let mut sections = handles + .iter() + .map(|handle| crate::scene::model::solid_history::primitive_properties(document, *handle)); let Some(mut merged) = sections.next() else { return Vec::new(); }; @@ -3330,9 +3331,7 @@ fn aggregate_solid_history_sections( merged } -fn retain_compact_solid_sections( - sections: &mut Vec, -) { +fn retain_compact_solid_sections(sections: &mut Vec) { sections.iter_mut().for_each(|section| { let internal_surface_section = section .props @@ -3419,12 +3418,10 @@ fn merge_prop_value( ( PropValue::FieldLwChoice { field, .. }, PropValue::FieldLwChoice { - field: other_field, - .. + field: other_field, .. }, ) if field == other_field => PropValue::FieldLwVaries { field }, - (PropValue::FieldLwVaries { field }, _) - | (_, PropValue::FieldLwVaries { field }) => { + (PropValue::FieldLwVaries { field }, _) | (_, PropValue::FieldLwVaries { field }) => { PropValue::FieldLwVaries { field } } (PropValue::LinetypeChoice(_), PropValue::LinetypeChoice(_)) => { @@ -3504,11 +3501,7 @@ fn merge_prop_value( /// Set the first property row matching `field` (across all sections) to a /// read-only `value`. No-op when the field is absent. Used to fill the /// doc-dependent placeholder rows the entity builders leave empty. -fn set_row( - sections: &mut [crate::scene::model::object::PropSection], - field: &str, - value: String, -) { +fn set_row(sections: &mut [crate::scene::model::object::PropSection], field: &str, value: String) { for section in sections.iter_mut() { if let Some(row) = section.props.iter_mut().find(|p| p.field == field) { row.value = crate::scene::model::object::PropValue::ReadOnly(value); @@ -3539,7 +3532,11 @@ fn update_row_text( ) { use crate::scene::model::object::PropValue; for section in sections.iter_mut() { - let Some(row) = section.props.iter_mut().find(|property| property.field == field) else { + let Some(row) = section + .props + .iter_mut() + .find(|property| property.field == field) + else { continue; }; match &mut row.value { @@ -3561,7 +3558,11 @@ fn update_row_toggle( ) { use crate::scene::model::object::PropValue; for section in sections.iter_mut() { - let Some(row) = section.props.iter_mut().find(|property| property.field == field) else { + let Some(row) = section + .props + .iter_mut() + .find(|property| property.field == field) + else { continue; }; match &mut row.value { @@ -3592,14 +3593,16 @@ fn update_row_color( acadrust::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"), }; for section in sections.iter_mut() { - let Some(row) = section.props.iter_mut().find(|property| property.field == field) else { + let Some(row) = section + .props + .iter_mut() + .find(|property| property.field == field) + else { continue; }; match &mut row.value { PropValue::ColorChoice(current) => *current = color, - PropValue::NamedColorChoice { .. } => { - row.value = PropValue::ColorChoice(color) - } + PropValue::NamedColorChoice { .. } => row.value = PropValue::ColorChoice(color), PropValue::ReadOnly(current) => *current = label, PropValue::ReadOnlyWithTooltip { value: current, .. } => *current = label, _ => {} @@ -3795,30 +3798,30 @@ fn format_unit_factor(factor: f64) -> String { /// Convert INSUNITS (DXF group 70) to millimetres. fn insunits_to_mm(code: i16) -> Option { Some(match code { - 1 => 25.4, // Inches - 2 => 304.8, // Feet - 3 => 1_609_344.0, // Miles - 4 => 1.0, // Millimeters - 5 => 10.0, // Centimeters - 6 => 1_000.0, // Meters - 7 => 1_000_000.0, // Kilometers - 8 => 0.000_025_4, // Microinches - 9 => 0.025_4, // Mils - 10 => 914.4, // Yards - 11 => 1.0e-7, // Angstroms - 12 => 1.0e-6, // Nanometers - 13 => 0.001, // Microns - 14 => 100.0, // Decimeters - 15 => 10_000.0, // Decameters - 16 => 100_000.0, // Hectometers - 17 => 1.0e12, // Gigameters - 18 => 1.495_978_707e14, // Astronomical Units - 19 => 9.460_730_472_580_8e18, // Light Years + 1 => 25.4, // Inches + 2 => 304.8, // Feet + 3 => 1_609_344.0, // Miles + 4 => 1.0, // Millimeters + 5 => 10.0, // Centimeters + 6 => 1_000.0, // Meters + 7 => 1_000_000.0, // Kilometers + 8 => 0.000_025_4, // Microinches + 9 => 0.025_4, // Mils + 10 => 914.4, // Yards + 11 => 1.0e-7, // Angstroms + 12 => 1.0e-6, // Nanometers + 13 => 0.001, // Microns + 14 => 100.0, // Decimeters + 15 => 10_000.0, // Decameters + 16 => 100_000.0, // Hectometers + 17 => 1.0e12, // Gigameters + 18 => 1.495_978_707e14, // Astronomical Units + 19 => 9.460_730_472_580_8e18, // Light Years 20 => 3.085_677_581_491_367_3e19, // Parsecs - 21 => 1_200_000.0 / 3_937.0, // US Survey Feet - 22 => 100_000.0 / 3_937.0, // US Survey Inches - 23 => 3_600_000.0 / 3_937.0, // US Survey Yards - 24 => 6_336_000_000.0 / 3_937.0, // US Survey Miles + 21 => 1_200_000.0 / 3_937.0, // US Survey Feet + 22 => 100_000.0 / 3_937.0, // US Survey Inches + 23 => 3_600_000.0 / 3_937.0, // US Survey Yards + 24 => 6_336_000_000.0 / 3_937.0, // US Survey Miles _ => return None, }) } @@ -3842,9 +3845,7 @@ fn apply_insert_unit_scale(ins: &mut acadrust::entities::Insert, ratio: f64) -> #[cfg(test)] mod insert_unit_scale_tests { - use super::{ - apply_insert_unit_scale, format_unit_factor, insert_unit_scale, insunits_to_mm, - }; + use super::{apply_insert_unit_scale, format_unit_factor, insert_unit_scale, insunits_to_mm}; use acadrust::entities::{AttributeEntity, Insert}; use acadrust::types::Vector3; @@ -3909,10 +3910,7 @@ mod insert_unit_scale_tests { fn astronomical_units_use_precise_si_values() { assert_eq!(insunits_to_mm(18), Some(1.495_978_707e14)); assert_eq!(insunits_to_mm(19), Some(9.460_730_472_580_8e18)); - assert_eq!( - insunits_to_mm(20), - Some(3.085_677_581_491_367_3e19) - ); + assert_eq!(insunits_to_mm(20), Some(3.085_677_581_491_367_3e19)); assert_ne!(format_unit_factor(1.0e-7), "0.0000"); } @@ -4004,11 +4002,7 @@ mod apply_property_op_tests { app.tabs[i].dirty = false; app.apply_property_op(i, "CHPROP", &[h1, h2], |app2, h| { count2.set(count2.get() + 1); - if let Some(e) = app2.tabs[app2.active_tab] - .scene - .document - .get_entity_mut(h) - { + if let Some(e) = app2.tabs[app2.active_tab].scene.document.get_entity_mut(h) { e.common_mut().color = Color::Index(7); } }); @@ -4038,19 +4032,22 @@ mod apply_property_op_tests { app.tabs[i].dirty = false; app.apply_property_op(i, "CHPROP", &[h1, missing], |app2, h| { count2.set(count2.get() + 1); - if let Some(e) = app2.tabs[app2.active_tab] - .scene - .document - .get_entity_mut(h) - { + if let Some(e) = app2.tabs[app2.active_tab].scene.document.get_entity_mut(h) { e.common_mut().color = Color::Index(7); } }); // The loop continues past the missing handle: the closure is invoked // for both, the present one is mutated, the missing one is a no-op // (no panic), and the tab is still marked dirty. - assert_eq!(count.get(), 2, "closure still called for the missing handle"); - assert!(app.tabs[i].dirty, "tab marked dirty even with a missing handle"); + assert_eq!( + count.get(), + 2, + "closure still called for the missing handle" + ); + assert!( + app.tabs[i].dirty, + "tab marked dirty even with a missing handle" + ); let e = app.tabs[i] .scene .document @@ -4111,32 +4108,68 @@ mod chprop_integration_tests { let _ = app.update(Message::PropColorChanged(Color::Index(5))); assert_eq!( - app.tabs[i].scene.document.get_entity(h1).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h1) + .unwrap() + .common() + .color, Color::Index(5) ); assert_eq!( - app.tabs[i].scene.document.get_entity(h2).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h2) + .unwrap() + .common() + .color, Color::Index(5) ); assert!(app.tabs[i].dirty, "helper must set the dirty bit"); app.undo_active_tab(); assert_eq!( - app.tabs[i].scene.document.get_entity(h1).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h1) + .unwrap() + .common() + .color, original ); assert_eq!( - app.tabs[i].scene.document.get_entity(h2).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h2) + .unwrap() + .common() + .color, original ); app.redo_active_tab(); assert_eq!( - app.tabs[i].scene.document.get_entity(h1).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h1) + .unwrap() + .common() + .color, Color::Index(5) ); assert_eq!( - app.tabs[i].scene.document.get_entity(h2).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h2) + .unwrap() + .common() + .color, Color::Index(5) ); } @@ -4164,14 +4197,26 @@ mod chprop_integration_tests { let _ = app.automation_op(r#"{"op":"select","type":"Line"}"#); let _ = app.automation_op(r#"{"op":"run","cmd":"CHPROP COLOR 3"}"#); assert_ne!( - app.tabs[i].scene.document.get_entity(h).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h) + .unwrap() + .common() + .color, original, "CHPROP COLOR 3 must have changed the line's colour" ); app.undo_active_tab(); assert_eq!( - app.tabs[i].scene.document.get_entity(h).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h) + .unwrap() + .common() + .color, original, "undo must restore the pre-CHPROP colour" ); @@ -4215,7 +4260,13 @@ mod chprop_integration_tests { let _ = app.update(Message::PropColorChanged(Color::Index(9))); assert_ne!( - app.tabs[i].scene.document.get_entity(h).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h) + .unwrap() + .common() + .color, Color::Index(9), "a locked-layer entity must not be mutated by PropColorChanged" ); @@ -4257,12 +4308,12 @@ mod grip_limit_tests { let handles: Vec<_> = (0..n) .map(|k| { let x = k as f64; - app.tabs[i].scene.add_entity(EntityType::Line( - Line::from_points( + app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( Vector3::new(x, 0.0, 0.0), Vector3::new(x + 1.0, 1.0, 0.0), - ), - )) + ))) }) .collect(); for handle in handles { @@ -4273,11 +4324,17 @@ mod grip_limit_tests { }; assert!( - select_n(crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize, None) > 0, + select_n( + crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize, + None + ) > 0, "a selection at the limit must still show its grips", ); assert_eq!( - select_n(crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize + 1, None), + select_n( + crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize + 1, + None + ), 0, "one past the limit must show none at all", ); @@ -4347,10 +4404,12 @@ mod grip_limit_tests { let handles: Vec<_> = (0..over) .map(|k| { let x = k as f64; - app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points( - Vector3::new(x, 0.0, 0.0), - Vector3::new(x + 1.0, 1.0, 0.0), - ))) + app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::new(x, 0.0, 0.0), + Vector3::new(x + 1.0, 1.0, 0.0), + ))) }) .collect(); for handle in handles { diff --git a/src/app/settings.rs b/src/app/settings.rs index 09f01584a..4607b72d6 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -283,10 +283,6 @@ pub struct UserSettings { /// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak /// handler on every launch. Toggle with the FILEASSOC command. pub file_assoc_enabled: bool, - /// When true, saving also writes sketch constraints as native drawing - /// objects alongside the application's own persistence record. - #[serde(default)] - pub write_dwg_native_constraints: bool, /// When true (default), a sketch constraint's viewport pill shows its /// glyph plus a driven value or named-parameter name. When false, every /// pill shows just the bare glyph, so the value/name text doesn't cover @@ -418,7 +414,6 @@ impl Default for UserSettings { textfill: true, backup_on_save: true, file_assoc_enabled: true, - write_dwg_native_constraints: false, show_constraint_values: true, savetime_min: 10, default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 7487d5b75..ac9b254cf 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -4,13 +4,12 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, - CoordKind, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; -use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::model::object::GripApply; +use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; @@ -59,9 +58,7 @@ fn plot_dialog_scale_factor(d: &crate::ui::window::plot::PlotDialogState) -> f64 fn scale_name_for_factor(scales: &[(String, f64)], factor: f64) -> Option { scales .iter() - .find(|(_, candidate)| { - (*candidate - factor).abs() <= 1e-6 * factor.abs().max(1.0) - }) + .find(|(_, candidate)| (*candidate - factor).abs() <= 1e-6 * factor.abs().max(1.0)) .map(|(name, _)| name.clone()) } @@ -83,10 +80,7 @@ fn plot_render_mode_override( #[cfg(not(target_arch = "wasm32"))] fn native_paths_match(left: &std::path::Path, right: &std::path::Path) -> bool { - match ( - std::fs::canonicalize(left), - std::fs::canonicalize(right), - ) { + match (std::fs::canonicalize(left), std::fs::canonicalize(right)) { (Ok(left), Ok(right)) => left == right, _ => left == right, } @@ -175,7 +169,10 @@ fn plot_content_extents( } for hatch in wipeouts.iter().chain(hatches.iter()) { for &[x, y] in hatch.boundary.iter() { - include(hatch.world_origin[0] + x as f64, hatch.world_origin[1] + y as f64); + include( + hatch.world_origin[0] + x as f64, + hatch.world_origin[1] + y as f64, + ); } } (bounds.0.is_finite() @@ -243,8 +240,7 @@ fn plot_scene_content( splits, ); } - let (mut model_pattern_wires, model_hatches, model_wipeouts) = - scene.viewport_plot_fills(); + let (mut model_pattern_wires, model_hatches, model_wipeouts) = scene.viewport_plot_fills(); model_pattern_wires.retain(|(wire, _)| wire.plot_visible); let model_pattern_wires = model_pattern_wires .into_iter() @@ -304,9 +300,7 @@ impl OpenCADStudio { for (h, needs_acis, needs_wires) in targets { let body = scene.solid_models.get(&h); let sat = needs_acis - .then(|| { - body.and_then(crate::scene::convert::acis_export::solid_to_sat) - }) + .then(|| body.and_then(crate::scene::convert::acis_export::solid_to_sat)) .flatten(); let wires = needs_wires.then(|| { if let Some(body) = body { @@ -434,7 +428,6 @@ impl OpenCADStudio { textfill: crate::scene::text::sdf_atlas::textfill(), backup_on_save: self.backup_on_save, file_assoc_enabled: self.file_assoc_enabled, - write_dwg_native_constraints: self.write_dwg_native_constraints, show_constraint_values: self.show_constraint_values, savetime_min: self.savetime_min, default_save_format: self.default_save_format.clone(), @@ -516,7 +509,6 @@ impl OpenCADStudio { crate::scene::text::sdf_atlas::set_textfill(s.textfill); self.backup_on_save = s.backup_on_save; self.file_assoc_enabled = s.file_assoc_enabled; - self.write_dwg_native_constraints = s.write_dwg_native_constraints; self.show_constraint_values = s.show_constraint_values; self.savetime_min = s.savetime_min; self.default_save_format = @@ -532,9 +524,8 @@ impl OpenCADStudio { .set_cliprompt_lines(self.cliprompt_lines.clamp(0, 50) as u8); self.commandline_fade_ms = crate::app::settings::clamp_commandline_fade_ms(s.commandline_fade_ms); - self.command_line.set_commandline_fade_ms( - self.commandline_fade_ms.clamp(0, 60000) as u32, - ); + self.command_line + .set_commandline_fade_ms(self.commandline_fade_ms.clamp(0, 60000) as u32); // Block usage: clone but cap to sane sizes (MRU 20, freq map 200) self.block_mru = s.block_mru.iter().take(20).cloned().collect(); self.block_freq = s @@ -620,7 +611,11 @@ impl OpenCADStudio { (n, f, m) }) .collect(); - scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)).then_with(|| a.0.cmp(b.0))); + scored.sort_by(|a, b| { + b.1.cmp(&a.1) + .then_with(|| a.2.cmp(&b.2)) + .then_with(|| a.0.cmp(b.0)) + }); scored.into_iter().map(|(n, _, _)| n.clone()).collect() } @@ -717,8 +712,7 @@ impl OpenCADStudio { /// Rebuild the ribbon's tab list from the registry, dropping the tabs of any /// disabled plugins. Call after `disabled_plugins` changes. pub(in crate::app) fn rebuild_ribbon_modules(&mut self) { - let modules = - crate::plugin::ribbon_modules_enabled(&self.disabled_plugins); + let modules = crate::plugin::ribbon_modules_enabled(&self.disabled_plugins); self.ribbon.set_modules(modules); // Refresh the command-line autocomplete pool so a newly enabled plugin's // commands become typeable (and a disabled one's drop out). This runs on @@ -880,10 +874,10 @@ impl OpenCADStudio { .iter() .map(std::path::PathBuf::from) .collect(); - self.recent_limit = cfg - .recent - .limit - .clamp(crate::app::recent::RECENT_MIN, crate::app::recent::RECENT_MAX); + self.recent_limit = cfg.recent.limit.clamp( + crate::app::recent::RECENT_MIN, + crate::app::recent::RECENT_MAX, + ); self.recent_files.truncate(self.recent_limit); self.recent_limit_input = self.recent_limit.to_string(); // Thumbnails are decoded by a background task queued at boot @@ -951,7 +945,8 @@ impl OpenCADStudio { tab.scene.recolor_meshes(); tab.scene.bump_geometry(); } - tab.scene.request_refresh(crate::scene::ViewportRefreshScope::All); + tab.scene + .request_refresh(crate::scene::ViewportRefreshScope::All); } } @@ -962,39 +957,39 @@ impl OpenCADStudio { self.persist_settings_if_changed(); } -pub(super) fn on_open_file(&mut self) -> Task { - // Native: pick a path, then load on a worker thread. Web: the - // browser hands back bytes, so pick + parse in one step and feed - // the shared `FileOpened` handler directly. - #[cfg(not(target_arch = "wasm32"))] - { - Task::perform(crate::io::pick_open_path(), Message::OpenPathPicked) - } - #[cfg(target_arch = "wasm32")] - { - // `FileOpened` only installs the result when an open is in - // progress, so mark one. The browser picker + parse happen - // inside `pick_and_load_web`; the real name is unknown until - // then, so show a generic label meanwhile. + pub(super) fn on_open_file(&mut self) -> Task { + // Native: pick a path, then load on a worker thread. Web: the + // browser hands back bytes, so pick + parse in one step and feed + // the shared `FileOpened` handler directly. + #[cfg(not(target_arch = "wasm32"))] + { + Task::perform(crate::io::pick_open_path(), Message::OpenPathPicked) + } + #[cfg(target_arch = "wasm32")] + { + // `FileOpened` only installs the result when an open is in + // progress, so mark one. The browser picker + parse happen + // inside `pick_and_load_web`; the real name is unknown until + // then, so show a generic label meanwhile. let state = std::sync::Arc::new(crate::io::OpenProgressState::new( crate::app::OPEN_PHASE_READING, )); - let open_id = self.next_open_id(); - self.opening = Some(crate::app::OpenProgress { - id: open_id, - name: "Opening…".into(), - source_path: None, - size_bytes: 0, - state: state.clone(), - started: Instant::now(), - recovery_error: None, - recovery_read_stats: None, - recovery_bytes: None, - }); - Task::perform(crate::io::pick_and_load_web(state), move |outcome| { - Message::WebFileOpened(open_id, outcome) - }) - } + let open_id = self.next_open_id(); + self.opening = Some(crate::app::OpenProgress { + id: open_id, + name: "Opening…".into(), + source_path: None, + size_bytes: 0, + state: state.clone(), + started: Instant::now(), + recovery_error: None, + recovery_read_stats: None, + recovery_bytes: None, + }); + Task::perform(crate::io::pick_and_load_web(state), move |outcome| { + Message::WebFileOpened(open_id, outcome) + }) + } } pub(in crate::app) fn next_open_id(&mut self) -> u64 { @@ -1032,10 +1027,7 @@ pub(super) fn on_open_file(&mut self) -> Task { /// Read through this session's lease when it covers `path`. #[cfg(not(target_arch = "wasm32"))] - pub(in crate::app) fn read_drawing( - &self, - path: &std::path::Path, - ) -> std::io::Result> { + pub(in crate::app) fn read_drawing(&self, path: &std::path::Path) -> std::io::Result> { use std::io::{Read, Seek, SeekFrom}; let lease = self @@ -1073,8 +1065,7 @@ pub(super) fn on_open_file(&mut self) -> Task { path: &std::path::Path, loaded_fingerprint: Option, ) { - let current_fingerprint = - crate::io::edit_lock::FileFingerprint::capture(path).ok(); + let current_fingerprint = crate::io::edit_lock::FileFingerprint::capture(path).ok(); if loaded_fingerprint .as_ref() .zip(current_fingerprint.as_ref()) @@ -1087,14 +1078,16 @@ pub(super) fn on_open_file(&mut self) -> Task { .as_ref(), ); } - self.tabs[i].disk_fingerprint = - loaded_fingerprint.or(current_fingerprint); + self.tabs[i].disk_fingerprint = loaded_fingerprint.or(current_fingerprint); match crate::io::edit_lock::EditLease::acquire(path) { Ok(lease) => { if let Some(warning) = lease.platform_warning() { - self.command_line.push_info(crate::tf!( - "Edit lease active; {warning}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!( + "Edit lease active; {warning}. External-change checks remain active." + ) + .as_ref(), + ); } self.tabs[i].edit_lease = Some(lease); self.tabs[i].edit_lock_conflict = false; @@ -1102,16 +1095,16 @@ pub(super) fn on_open_file(&mut self) -> Task { Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => { self.tabs[i].edit_lease = None; self.tabs[i].edit_lock_conflict = true; - self.command_line.push_error_once(crate::tf!( - "Opened read-only against other editors: {error}" - ).as_ref()); + self.command_line.push_error_once( + crate::tf!("Opened read-only against other editors: {error}").as_ref(), + ); } Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => { self.tabs[i].edit_lease = None; self.tabs[i].edit_lock_conflict = false; - self.command_line.push_info(crate::tf!( - "{error}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{error}. External-change checks remain active.").as_ref(), + ); } } } @@ -1143,27 +1136,29 @@ pub(super) fn on_open_file(&mut self) -> Task { Some(Ok(warning)) => { self.tabs[i].edit_lock_conflict = false; if let Some(warning) = warning { - self.command_line.push_info(crate::tf!( - "{warning}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{warning}. External-change checks remain active.").as_ref(), + ); } } Some(Err(crate::io::edit_lock::EditLeaseError::Locked(error))) => { self.tabs[i].edit_lock_conflict = true; - self.command_line.push_error_once(crate::tf!( - "Saved, but the refreshed drawing is locked by another editor: {error}" - ).as_ref()); + self.command_line.push_error_once( + crate::tf!( + "Saved, but the refreshed drawing is locked by another editor: {error}" + ) + .as_ref(), + ); } Some(Err(crate::io::edit_lock::EditLeaseError::Unavailable(error))) => { self.tabs[i].edit_lock_conflict = false; - self.command_line.push_info(crate::tf!( - "{error}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{error}. External-change checks remain active.").as_ref(), + ); } None => self.install_native_edit_guard(i, path, None), } - self.tabs[i].disk_fingerprint = - crate::io::edit_lock::FileFingerprint::capture(path).ok(); + self.tabs[i].disk_fingerprint = crate::io::edit_lock::FileFingerprint::capture(path).ok(); } #[cfg(not(target_arch = "wasm32"))] @@ -1201,9 +1196,9 @@ pub(super) fn on_open_file(&mut self) -> Task { return Err(crate::io::SaveFailure::file_in_use(error)); } Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => { - self.command_line.push_info(crate::tf!( - "{error}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{error}. External-change checks remain active.").as_ref(), + ); None } } @@ -1239,12 +1234,7 @@ pub(super) fn on_open_file(&mut self) -> Task { if set_current_path { self.tabs[i].current_path = Some(path.clone()); } - self.refresh_native_edit_guard_after_save( - i, - &path, - path_changed, - destination_lease, - ); + self.refresh_native_edit_guard_after_save(i, &path, path_changed, destination_lease); self.tabs[i].dirty = false; if set_current_path { self.tabs[i].recovery_save_as_required = false; @@ -1254,20 +1244,16 @@ pub(super) fn on_open_file(&mut self) -> Task { } #[cfg(not(target_arch = "wasm32"))] - fn retry_native_edit_guard( - &mut self, - i: usize, - path: &std::path::Path, - ) -> Result<(), String> { + fn retry_native_edit_guard(&mut self, i: usize, path: &std::path::Path) -> Result<(), String> { if let Some(lease) = self.tabs[i].edit_lease.as_mut() { match lease.refresh_drawing_lock(path) { Ok(()) => { let warning = lease.platform_warning().map(str::to_owned); self.tabs[i].edit_lock_conflict = false; if let Some(warning) = warning { - self.command_line.push_info(crate::tf!( - "{warning}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{warning}. External-change checks remain active.").as_ref(), + ); } return Ok(()); } @@ -1276,9 +1262,9 @@ pub(super) fn on_open_file(&mut self) -> Task { } Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => { self.tabs[i].edit_lock_conflict = false; - self.command_line.push_info(crate::tf!( - "{error}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{error}. External-change checks remain active.").as_ref(), + ); return Ok(()); } } @@ -1290,75 +1276,84 @@ pub(super) fn on_open_file(&mut self) -> Task { self.tabs[i].edit_lease = Some(lease); self.tabs[i].edit_lock_conflict = false; if let Some(warning) = warning { - self.command_line.push_info(crate::tf!( - "{warning}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{warning}. External-change checks remain active.").as_ref(), + ); } Ok(()) } Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => Err(error), Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => { self.tabs[i].edit_lock_conflict = false; - self.command_line.push_info(crate::tf!( - "{error}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{error}. External-change checks remain active.").as_ref(), + ); Ok(()) } } } - pub(super) fn on_file_opened(&mut self, name: String, path: std::path::PathBuf, doc: acadrust::CadDocument, + pub(super) fn on_file_opened( + &mut self, + name: String, + path: std::path::PathBuf, + doc: acadrust::CadDocument, mut caches: crate::scene::DerivedCaches, ) -> Task { - // If the user clicked Cancel while the parser was running, the - // overlay state was cleared and we silently drop the result. - if self.opening.is_none() { - return Task::none(); - } - let open_started = self.opening.as_ref().map(|p| p.started); - let size_bytes = self - .opening - .as_ref() - .map(|progress| progress.size_bytes) - .unwrap_or(0); - let timings = caches.timings; - let entity_count = doc.entities().count(); - let parser_errors_recovered = caches.read_stats.as_ref().is_some_and(|stats| { - stats.recovered() - || stats.skipped_source_records > 0 - || !stats.stream_completed - }) || doc.notifications.iter().any(|item| { - item.notification_type == acadrust::notification::NotificationType::Error - }); - let reference_recovered = caches - .xrefs - .iter() - .any(|item| item.status == crate::io::xref::XrefStatus::Recovered); - let reference_failed = caches - .xrefs - .iter() - .any(|item| item.status == crate::io::xref::XrefStatus::Failed); - let document_repaired = parser_errors_recovered - || reference_recovered - || caches.corrupt_dropped > 0 - || caches.xref_dropped > 0; - let recovery_needed = document_repaired || reference_failed; - let total_ms = open_started - .map(|started| started.elapsed().as_millis() as u32) - .unwrap_or(0); - self.command_line - .push_output(crate::tf!("Opened \"{name}\" — {entity_count} entities").as_ref()); - if caches.corrupt_dropped > 0 { - self.command_line.push_error(crate::tf!( - "Warning: {} corrupt entities dropped (parser junk — bad normals / counts)", - caches.corrupt_dropped - ).as_ref()); - } + // If the user clicked Cancel while the parser was running, the + // overlay state was cleared and we silently drop the result. + if self.opening.is_none() { + return Task::none(); + } + let open_started = self.opening.as_ref().map(|p| p.started); + let size_bytes = self + .opening + .as_ref() + .map(|progress| progress.size_bytes) + .unwrap_or(0); + let timings = caches.timings; + let entity_count = doc.entities().count(); + let parser_errors_recovered = caches.read_stats.as_ref().is_some_and(|stats| { + stats.recovered() || stats.skipped_source_records > 0 || !stats.stream_completed + }) || doc + .notifications + .iter() + .any(|item| item.notification_type == acadrust::notification::NotificationType::Error); + let reference_recovered = caches + .xrefs + .iter() + .any(|item| item.status == crate::io::xref::XrefStatus::Recovered); + let reference_failed = caches + .xrefs + .iter() + .any(|item| item.status == crate::io::xref::XrefStatus::Failed); + let document_repaired = parser_errors_recovered + || reference_recovered + || caches.corrupt_dropped > 0 + || caches.xref_dropped > 0; + let recovery_needed = document_repaired || reference_failed; + let total_ms = open_started + .map(|started| started.elapsed().as_millis() as u32) + .unwrap_or(0); + self.command_line + .push_output(crate::tf!("Opened \"{name}\" — {entity_count} entities").as_ref()); + if caches.corrupt_dropped > 0 { + self.command_line.push_error( + crate::tf!( + "Warning: {} corrupt entities dropped (parser junk — bad normals / counts)", + caches.corrupt_dropped + ) + .as_ref(), + ); + } if caches.xref_dropped > 0 { - self.command_line.push_error(crate::tf!( - "Warning: {} corrupt xref entities dropped", - caches.xref_dropped - ).as_ref()); + self.command_line.push_error( + crate::tf!( + "Warning: {} corrupt xref entities dropped", + caches.xref_dropped + ) + .as_ref(), + ); } for info in &caches.xrefs { match info.status { @@ -1367,341 +1362,345 @@ pub(super) fn on_open_file(&mut self) -> Task { .push_output(crate::tf!("XREF Loaded \"{}\"", info.name).as_ref()); } crate::io::xref::XrefStatus::Recovered => { - self.command_line.push_error(crate::tf!( - "XREF Recovered with warnings: \"{}\"", - info.name - ).as_ref()); + self.command_line.push_error( + crate::tf!("XREF Recovered with warnings: \"{}\"", info.name).as_ref(), + ); } crate::io::xref::XrefStatus::NotFound => { - self.command_line.push_error(crate::tf!( - "XREF Not found: \"{}\" ({})", - info.name, info.path - ).as_ref()); + self.command_line.push_error( + crate::tf!("XREF Not found: \"{}\" ({})", info.name, info.path).as_ref(), + ); } crate::io::xref::XrefStatus::Failed => { - self.command_line.push_error(crate::tf!( - "XREF Recovery failed: \"{}\" ({})", - info.name, info.path - ).as_ref()); + self.command_line.push_error( + crate::tf!("XREF Recovery failed: \"{}\" ({})", info.name, info.path) + .as_ref(), + ); } crate::io::xref::XrefStatus::Unloaded => { - self.command_line - .push_info(crate::tf!("XREF Unloaded (skipped): \"{}\"", info.name).as_ref()); + self.command_line.push_info( + crate::tf!("XREF Unloaded (skipped): \"{}\"", info.name).as_ref(), + ); } } } - #[cfg(not(target_arch = "wasm32"))] - let thumbs_task = self.push_recent(path.clone()); - #[cfg(target_arch = "wasm32")] - let thumbs_task = Task::none(); + #[cfg(not(target_arch = "wasm32"))] + let thumbs_task = self.push_recent(path.clone()); + #[cfg(target_arch = "wasm32")] + let thumbs_task = Task::none(); - let current_is_empty = { - let t = &self.tabs[self.active_tab]; - !t.is_start - && t.current_path.is_none() - && !t.dirty - && self.tabs[self.active_tab].scene.document.entities().count() == 0 - }; - let i = if current_is_empty { - self.active_tab - } else { - self.tab_counter += 1; - let new_tab = crate::app::document::DocumentTab::new_drawing(self.tab_counter); - self.tabs.push(new_tab); - let idx = self.tabs.len() - 1; - self.active_tab = idx; - self.apply_display_defaults(idx); - idx - }; + let current_is_empty = { + let t = &self.tabs[self.active_tab]; + !t.is_start + && t.current_path.is_none() + && !t.dirty + && self.tabs[self.active_tab].scene.document.entities().count() == 0 + }; + let i = if current_is_empty { + self.active_tab + } else { + self.tab_counter += 1; + let new_tab = crate::app::document::DocumentTab::new_drawing(self.tab_counter); + self.tabs.push(new_tab); + let idx = self.tabs.len() - 1; + self.active_tab = idx; + self.apply_display_defaults(idx); + idx + }; - let mut recovery_report = recovery_needed.then(|| { - crate::io::recovery::RecoveryReport::recovered( - self.tabs[i].id, - &path, - size_bytes, - caches.source_sha256.clone(), - caches.read_stats.clone(), - entity_count.saturating_add(caches.corrupt_dropped), - caches.corrupt_dropped, - caches.xref_dropped, - &caches.xrefs, - &doc.notifications, - document_repaired, - timings, - total_ms, - ) - }); - if let Some(report) = recovery_report.as_mut() { - report.persist(); - } + let mut recovery_report = recovery_needed.then(|| { + crate::io::recovery::RecoveryReport::recovered( + self.tabs[i].id, + &path, + size_bytes, + caches.source_sha256.clone(), + caches.read_stats.clone(), + entity_count.saturating_add(caches.corrupt_dropped), + caches.corrupt_dropped, + caches.xref_dropped, + &caches.xrefs, + &doc.notifications, + document_repaired, + timings, + total_ms, + ) + }); + if let Some(report) = recovery_report.as_mut() { + report.persist(); + } - #[cfg(not(target_arch = "wasm32"))] - let opened_fingerprint = self - .opening - .as_ref() - .and_then(|opening| opening.fingerprint.clone()); - self.tabs[i].current_path = Some(path.clone()); - #[cfg(not(target_arch = "wasm32"))] - self.install_native_edit_guard(i, &path, opened_fingerprint); - self.tabs[i].scene.material_base_dir = - path.parent().map(std::path::Path::to_path_buf); - self.tabs[i].scene.document = doc; - // Load parameters first so imported dimensional constraints - // can resolve their named driving values. - self.tabs[i].scene.load_named_parameters_from_document(); - self.tabs[i].scene.load_sketch_constraints_from_document(); - self.tabs[i].active_layer = self.tabs[i] - .scene - .document - .header - .current_layer_name - .clone(); - // A file saved without the built-in Standard styles (foreign - // or damaged) gets them re-seeded so nothing dangles (#366). - crate::app::style_ops::ensure_standard_styles( - &mut self.tabs[i].scene.document, - ); - // Follow the file's saved current UCS from the moment it opens. - self.tabs[i].adopt_active_ucs_from_header(); - // Adopt the drawing's own Ortho ($ORTHOMODE) and running OSNAP - // ($OSMODE) so each file keeps its own state. - self.adopt_header_sysvars(i); - // Route shared CJK ideographs to the language matching this - // drawing's code page (web per-language font split). Drop the - // glyph cache if it changed so Han re-resolves to the new - // language's font; geometry is (re)built below regardless. (#141) - if crate::scene::text::web_font::set_cjk_lang_from_codepage( - &self.tabs[i].scene.document.header.code_page, - ) { - crate::scene::text::ttf_glyph::clear_fallback_cache(); - } - // Current model-space annotation scale comes from the drawing's - // CANNOSCALEVALUE (paper/drawing factor). Convert its inverse into - // drawing units as well: metric annotation sizes are paper millimetres - // and imperial annotation sizes are paper inches. - // Current model-space annotation scale comes from the drawing's - // CANNOSCALEVALUE (paper/drawing factor). Convert its paper unit into - // the drawing's INSUNITS as well, so e.g. a metre drawing uses - // 0.001 model units for 1 mm of paper at 1:1. - let cannoscale_value = self.tabs[i].scene.document.header.annotation_scale_value; - let unit_factor = self.tabs[i].scene.annotation_scale_unit_factor(); + #[cfg(not(target_arch = "wasm32"))] + let opened_fingerprint = self + .opening + .as_ref() + .and_then(|opening| opening.fingerprint.clone()); + self.tabs[i].current_path = Some(path.clone()); + #[cfg(not(target_arch = "wasm32"))] + self.install_native_edit_guard(i, &path, opened_fingerprint); + self.tabs[i].scene.material_base_dir = path.parent().map(std::path::Path::to_path_buf); + self.tabs[i].scene.document = doc; + // Load parameters first so imported dimensional constraints + // can resolve their named driving values. + self.tabs[i].scene.load_named_parameters_from_document(); + self.tabs[i] + .scene + .load_parametric_constraints_from_document(); + self.tabs[i].active_layer = self.tabs[i] + .scene + .document + .header + .current_layer_name + .clone(); + // A file saved without the built-in Standard styles (foreign + // or damaged) gets them re-seeded so nothing dangles (#366). + crate::app::style_ops::ensure_standard_styles(&mut self.tabs[i].scene.document); + // Follow the file's saved current UCS from the moment it opens. + self.tabs[i].adopt_active_ucs_from_header(); + // Adopt the drawing's own Ortho ($ORTHOMODE) and running OSNAP + // ($OSMODE) so each file keeps its own state. + self.adopt_header_sysvars(i); + // Route shared CJK ideographs to the language matching this + // drawing's code page (web per-language font split). Drop the + // glyph cache if it changed so Han re-resolves to the new + // language's font; geometry is (re)built below regardless. (#141) + if crate::scene::text::web_font::set_cjk_lang_from_codepage( + &self.tabs[i].scene.document.header.code_page, + ) { + crate::scene::text::ttf_glyph::clear_fallback_cache(); + } + // Current model-space annotation scale comes from the drawing's + // CANNOSCALEVALUE (paper/drawing factor). Convert its inverse into + // drawing units as well: metric annotation sizes are paper millimetres + // and imperial annotation sizes are paper inches. + // Current model-space annotation scale comes from the drawing's + // CANNOSCALEVALUE (paper/drawing factor). Convert its paper unit into + // the drawing's INSUNITS as well, so e.g. a metre drawing uses + // 0.001 model units for 1 mm of paper at 1:1. + let cannoscale_value = self.tabs[i].scene.document.header.annotation_scale_value; + let unit_factor = self.tabs[i].scene.annotation_scale_unit_factor(); - self.tabs[i].scene.annotation_scale = if cannoscale_value > 1e-9 { - ((1.0 / cannoscale_value) / unit_factor) as f32 - } else { - (1.0 / unit_factor) as f32 - }; + self.tabs[i].scene.annotation_scale = if cannoscale_value > 1e-9 { + ((1.0 / cannoscale_value) / unit_factor) as f32 + } else { + (1.0 / unit_factor) as f32 + }; - // Open-time breakdown so regressions are visible immediately. - // `total` is wall time from the Open click to here (post-xref, - // pre-first-frame); the phase figures are the background-thread - // parse/purge/cache spans plus the UI-thread xref resolve. - self.command_line.push_info(crate::tf!( - " parse {}ms · purge {}ms · caches {}ms · xref {}ms · total {}ms", - timings.parse_ms, timings.purge_ms, timings.caches_ms, timings.xref_ms, total_ms - ).as_ref()); + // Open-time breakdown so regressions are visible immediately. + // `total` is wall time from the Open click to here (post-xref, + // pre-first-frame); the phase figures are the background-thread + // parse/purge/cache spans plus the UI-thread xref resolve. + self.command_line.push_info( + crate::tf!( + " parse {}ms · purge {}ms · caches {}ms · xref {}ms · total {}ms", + timings.parse_ms, + timings.purge_ms, + timings.caches_ms, + timings.xref_ms, + total_ms + ) + .as_ref(), + ); - // Caches were built on the background thread inside open_path(). - self.tabs[i].scene.local_extent_max = caches.local_extent_max; - self.tabs[i].scene.local_center = caches.local_center; - self.tabs[i].scene.hatches = caches.hatches; - self.tabs[i].scene.images = caches.images; - self.tabs[i].scene.meshes = caches.meshes; - self.tabs[i].scene.block_meshes = caches.block_meshes; - self.tabs[i].scene.object_data_cache = caches.object_data; + // Caches were built on the background thread inside open_path(). + self.tabs[i].scene.local_extent_max = caches.local_extent_max; + self.tabs[i].scene.local_center = caches.local_center; + self.tabs[i].scene.hatches = caches.hatches; + self.tabs[i].scene.images = caches.images; + self.tabs[i].scene.meshes = caches.meshes; + self.tabs[i].scene.block_meshes = caches.block_meshes; + self.tabs[i].scene.object_data_cache = caches.object_data; let prepared_geometry = caches.prepared_geometry.take(); - // Invalidate the wire cache so the new document is tessellated. - self.tabs[i].scene.bump_geometry(); - if let Some(prepared) = prepared_geometry { - self.tabs[i].scene.install_prepared_open_geometry(prepared); - } - // Pay for the interaction index here, not on the first hover. - self.tabs[i].scene.warm_interaction_caches(); - self.tabs[i] - .scene - .replace_selection(rustc_hash::FxHashSet::default()); - self.tabs[i].scene.preview_wires = vec![]; - // Reopen in whichever space the file was saved in — the CTAB - // tab name when recorded, else the $TILEMODE model/paper flag — - // instead of always landing in Model. - { - let names = self.tabs[i].scene.layout_names(); - let saved = crate::io::saved_active_layout(&self.tabs[i].scene.document) - .and_then(|n| { - names.iter().find(|x| x.eq_ignore_ascii_case(&n)).cloned() - }); - self.tabs[i].scene.current_layout = match saved { - Some(n) => n, - None if self.tabs[i].scene.document.header.show_model_space => { - "Model".to_string() - } - // Paper was active but no CTAB — fall back to the first - // paper layout (names[0] is always "Model"). - None => names - .into_iter() - .nth(1) - .unwrap_or_else(|| "Model".to_string()), - }; - self.tabs[i].scene.load_current_layout_state(); - self.tabs[i].refresh_active_ucs(); - } - // Object isolation is session-only. A newly opened drawing must - // not inherit the previous tab's filter, and persisted entity - // visibility remains independent (not an isolation session). - self.tabs[i].scene.reset_transient_visibility(); - crate::io::linetypes::populate_document(&mut self.tabs[i].scene.document); - self.tabs[i].properties = PropertiesPanel::empty(); - // Seed the current table / multileader style from the file's - // header so the ✓ marks the right one (text/dim/mline come from - // the document header directly). DXF provides these via - // $CTABLESTYLE / $CMLEADERSTYLE; DWG leaves them at "Standard". - self.ribbon.active_table_style = self.tabs[i] - .scene - .document - .header - .current_table_style_name - .clone(); - self.tabs[i].active_mleader_style = self.tabs[i] - .scene - .document - .header - .current_mleader_style_name - .clone(); - let doc_layers = self.tabs[i].scene.document.layers.clone(); - let vp_info = self.tabs[i].scene.viewport_list(); - self.tabs[i] - .layers - .sync_with_viewports(&doc_layers, vp_info); - self.sync_ribbon_layers(); - // Load the Annotate-ribbon style dropdowns (text / dimension / - // multileader / table) from the opened document instead of - // leaving them on the hard-coded "Standard" default. - self.sync_ribbon_styles(); - // Reset the Home-ribbon Color / Linetype / Lineweight chips - // to the newly opened document's CECOLOR / CELTYPE / CELWEIGHT - // defaults (or to ByLayer when the file leaves them empty). - // Without this they stick to whatever the prior tab had - // selected — see #21. - self.sync_ribbon_from_selection(); - self.tabs[i].scene.restore_saved_camera(); - // Grid/snap are per-drawing view settings — adopt the opened - // file's active viewport state rather than a global preference. - self.adopt_view_display(i); - self.sync_render_mode_to_active_tile(i); - self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; - self.tabs[i].dirty = document_repaired; - self.tabs[i].recovery_save_as_required = document_repaired; - self.tabs[i].history = crate::app::document::HistoryState::default(); - self.refresh_properties(); - #[cfg(not(target_arch = "wasm32"))] - let interaction_task = { - let wires = self.tabs[i].scene.hit_test_wires(); - let screen_height = self.tabs[i].scene.selection.borrow().vp_size.1; - self.prepare_interaction_index_task(i, wires, screen_height) - .unwrap_or_else(Task::none) - }; - #[cfg(target_arch = "wasm32")] - let interaction_task = Task::none(); + // Invalidate the wire cache so the new document is tessellated. + self.tabs[i].scene.bump_geometry(); + if let Some(prepared) = prepared_geometry { + self.tabs[i].scene.install_prepared_open_geometry(prepared); + } + // Pay for the interaction index here, not on the first hover. + self.tabs[i].scene.warm_interaction_caches(); + self.tabs[i] + .scene + .replace_selection(rustc_hash::FxHashSet::default()); + self.tabs[i].scene.preview_wires = vec![]; + // Reopen in whichever space the file was saved in — the CTAB + // tab name when recorded, else the $TILEMODE model/paper flag — + // instead of always landing in Model. + { + let names = self.tabs[i].scene.layout_names(); + let saved = crate::io::saved_active_layout(&self.tabs[i].scene.document) + .and_then(|n| names.iter().find(|x| x.eq_ignore_ascii_case(&n)).cloned()); + self.tabs[i].scene.current_layout = match saved { + Some(n) => n, + None if self.tabs[i].scene.document.header.show_model_space => "Model".to_string(), + // Paper was active but no CTAB — fall back to the first + // paper layout (names[0] is always "Model"). + None => names + .into_iter() + .nth(1) + .unwrap_or_else(|| "Model".to_string()), + }; + self.tabs[i].scene.load_current_layout_state(); + self.tabs[i].refresh_active_ucs(); + } + // Object isolation is session-only. A newly opened drawing must + // not inherit the previous tab's filter, and persisted entity + // visibility remains independent (not an isolation session). + self.tabs[i].scene.reset_transient_visibility(); + crate::io::linetypes::populate_document(&mut self.tabs[i].scene.document); + self.tabs[i].properties = PropertiesPanel::empty(); + // Seed the current table / multileader style from the file's + // header so the ✓ marks the right one (text/dim/mline come from + // the document header directly). DXF provides these via + // $CTABLESTYLE / $CMLEADERSTYLE; DWG leaves them at "Standard". + self.ribbon.active_table_style = self.tabs[i] + .scene + .document + .header + .current_table_style_name + .clone(); + self.tabs[i].active_mleader_style = self.tabs[i] + .scene + .document + .header + .current_mleader_style_name + .clone(); + let doc_layers = self.tabs[i].scene.document.layers.clone(); + let vp_info = self.tabs[i].scene.viewport_list(); + self.tabs[i] + .layers + .sync_with_viewports(&doc_layers, vp_info); + self.sync_ribbon_layers(); + // Load the Annotate-ribbon style dropdowns (text / dimension / + // multileader / table) from the opened document instead of + // leaving them on the hard-coded "Standard" default. + self.sync_ribbon_styles(); + // Reset the Home-ribbon Color / Linetype / Lineweight chips + // to the newly opened document's CECOLOR / CELTYPE / CELWEIGHT + // defaults (or to ByLayer when the file leaves them empty). + // Without this they stick to whatever the prior tab had + // selected — see #21. + self.sync_ribbon_from_selection(); + self.tabs[i].scene.restore_saved_camera(); + // Grid/snap are per-drawing view settings — adopt the opened + // file's active viewport state rather than a global preference. + self.adopt_view_display(i); + self.sync_render_mode_to_active_tile(i); + self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; + self.tabs[i].dirty = document_repaired; + self.tabs[i].recovery_save_as_required = document_repaired; + self.tabs[i].history = crate::app::document::HistoryState::default(); + self.refresh_properties(); + #[cfg(not(target_arch = "wasm32"))] + let interaction_task = { + let wires = self.tabs[i].scene.hit_test_wires(); + let screen_height = self.tabs[i].scene.selection.borrow().vp_size.1; + self.prepare_interaction_index_task(i, wires, screen_height) + .unwrap_or_else(Task::none) + }; + #[cfg(target_arch = "wasm32")] + let interaction_task = Task::none(); if let Some(opening) = &self.opening { opening .state .set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1); } self.opening.take(); - let pending_open_task = if let Some(report) = recovery_report { - self.recovery_report = Some(report); - self.active_modal = Some(crate::app::ModalKind::Recovery); - Task::none() - } else { - self.drain_pending_open() - }; - Task::batch([thumbs_task, pending_open_task, interaction_task]) + let pending_open_task = if let Some(report) = recovery_report { + self.recovery_report = Some(report); + self.active_modal = Some(crate::app::ModalKind::Recovery); + Task::none() + } else { + self.drain_pending_open() + }; + Task::batch([thumbs_task, pending_open_task, interaction_task]) } - pub(super) fn on_wblock_save_result_some(&mut self, block_name: String, path: std::path::PathBuf, + pub(super) fn on_wblock_save_result_some( + &mut self, + block_name: String, + path: std::path::PathBuf, ) -> Task { - let i = self.active_tab; - let document = self.tabs[i].scene.document.clone(); - let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); + let i = self.active_tab; + let document = self.tabs[i].scene.document.clone(); + let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); let worker_name = block_name.clone(); let worker_path = path.clone(); background_task( move || { let document = if worker_name == "*" { - crate::modules::insert::wblock::extract_entities_to_doc( - &document, - &handles, - ) + crate::modules::insert::wblock::extract_entities_to_doc(&document, &handles) } else { - crate::modules::insert::wblock::extract_block_to_doc( - &document, - &worker_name) + crate::modules::insert::wblock::extract_block_to_doc(&document, &worker_name) } - .map_err(|e| e.to_string())?; crate::io::save(&document, &worker_path).map_err(|e| e.to_string()) - }, - move |result| Message::WblockWriteFinished(block_name, path, result),) + .map_err(|e| e.to_string())?; + crate::io::save(&document, &worker_path).map_err(|e| e.to_string()) + }, + move |result| Message::WblockWriteFinished(block_name, path, result), + ) } pub(super) fn on_stl_export_path_some(&mut self, path: std::path::PathBuf) -> Task { - // Re-build STL bytes (we can't easily pass them through the message). - let i = self.active_tab; - // STL gets the highest-resolution LOD (slot 0) so the - // exported geometry isn't downgraded by the view-dependent - // mesh LOD ladder used for rendering. - let meshes: Vec = self.tabs[i] - .scene - .meshes - .values() - .filter_map(|s| s.lods.first().cloned()) - .collect(); - let worker_path = path.clone(); + // Re-build STL bytes (we can't easily pass them through the message). + let i = self.active_tab; + // STL gets the highest-resolution LOD (slot 0) so the + // exported geometry isn't downgraded by the view-dependent + // mesh LOD ladder used for rendering. + let meshes: Vec = self.tabs[i] + .scene + .meshes + .values() + .filter_map(|s| s.lods.first().cloned()) + .collect(); + let worker_path = path.clone(); background_task( move || { let mesh_refs: Vec<_> = meshes.iter().collect(); let bytes = crate::io::stl::build_stl(&mesh_refs) .ok_or_else(|| "no mesh data to export".to_string())?; std::fs::write(&worker_path, bytes).map_err(|e| e.to_string()) - }, - move |result| Message::StlExportFinished(path, result),) + }, + move |result| Message::StlExportFinished(path, result), + ) } pub(super) fn on_step_export_path_some(&mut self, path: std::path::PathBuf) -> Task { - let i = self.active_tab; - // Export uses LOD 0 (full resolution); see StlExportPath above. - let meshes: Vec = self.tabs[i] - .scene - .meshes - .values() - .filter_map(|s| s.lods.first().cloned()) - .collect(); - let worker_path = path.clone(); + let i = self.active_tab; + // Export uses LOD 0 (full resolution); see StlExportPath above. + let meshes: Vec = self.tabs[i] + .scene + .meshes + .values() + .filter_map(|s| s.lods.first().cloned()) + .collect(); + let worker_path = path.clone(); background_task( move || { let mesh_refs: Vec<_> = meshes.iter().collect(); let text = crate::io::step::build_step(&mesh_refs) .ok_or_else(|| "no mesh data to export".to_string())?; std::fs::write(&worker_path, text.as_bytes()).map_err(|e| e.to_string()) - }, - move |result| Message::StepExportFinished(path, result),) + }, + move |result| Message::StepExportFinished(path, result), + ) } pub(super) fn on_obj_import_path_some(&mut self, path: std::path::PathBuf) -> Task { - let tab_id = self.tabs[self.active_tab].id; + let tab_id = self.tabs[self.active_tab].id; let worker_path = path.clone(); background_task( move || { - let src = std::fs::read_to_string(&worker_path).map_err(|e| e.to_string())?; crate::io::obj::parse_obj(&src, [0.7, 0.7, 0.85, 1.0]) - .ok_or_else(|| "no usable geometry in file".to_string()) + let src = std::fs::read_to_string(&worker_path).map_err(|e| e.to_string())?; + crate::io::obj::parse_obj(&src, [0.7, 0.7, 0.85, 1.0]) + .ok_or_else(|| "no usable geometry in file".to_string()) }, - move |result| Message::ObjImportFinished(tab_id, path, result),) + move |result| Message::ObjImportFinished(tab_id, path, result), + ) } fn sync_view_state_for_save(&mut self, i: usize) { self.sync_vport_display(i); if self.tabs[i].active_block_edit.is_none() { self.tabs[i].scene.sync_camera_to_document(); - self.tabs[i].last_synced_camera_gen = - self.tabs[i].scene.camera_generation; + self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; } } @@ -1710,16 +1709,9 @@ pub(super) fn on_open_file(&mut self) -> Task { self.sync_view_state_for_save(i); sync_annotation_scale_header(&mut self.tabs[i].scene); self.stamp_header_sysvars(i); - self.tabs[i].scene.document.header.user_real1 = - self.tabs[i].scene.annotation_scale as f64; + self.tabs[i].scene.document.header.user_real1 = self.tabs[i].scene.annotation_scale as f64; self.sync_solid_models_for_save(i); - // Materialize constraints immediately before serialization. - self.tabs[i].scene.materialize_sketch_constraints_for_save(); - // named_parameters_design.md stage 2: same save-time hook, for the - // document-wide parameter table. - self.tabs[i].scene.materialize_named_parameters_for_save(); - // Keep the optional native constraint graph synchronized on save. - self.tabs[i].scene.materialize_dwg_native_constraints_for_save(self.write_dwg_native_constraints); + self.tabs[i].scene.sync_native_parametric_graph(); } #[cfg(not(target_arch = "wasm32"))] @@ -1836,11 +1828,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } let mut recent_task = Task::none(); - let saved = match crate::io::save_to_bytes( - &self.tabs[i].scene.document, - &ext, - version, - ) { + let saved = match crate::io::save_to_bytes(&self.tabs[i].scene.document, &ext, version) { Ok(bytes) => { crate::sys::download_bytes(&filename, &bytes); let cache_name = std::path::Path::new(&filename) @@ -1928,13 +1916,11 @@ pub(super) fn on_open_file(&mut self) -> Task { && self.tabs[i].recovery_save_as_required && destination_is_current { - self.command_line.push_error_once( - crate::tr!("recovery", "save-new-file-required").as_ref(), - ); + self.command_line + .push_error_once(crate::tr!("recovery", "save-new-file-required").as_ref()); self.restore_failed_save_continuation(continuation, i); self.active_tab = i; - self.save_dialog_for_unsaved = - continuation != crate::app::SaveContinuation::None; + self.save_dialog_for_unsaved = continuation != crate::app::SaveContinuation::None; if capture_ready { self.thumbnail_capture_clean = false; } @@ -1945,12 +1931,15 @@ pub(super) fn on_open_file(&mut self) -> Task { && self.tabs[i].edit_lock_conflict { let error = "Drawing edit lock is held by another editor.".to_string(); - self.command_line.push_error_once(crate::tf!( - "Unable to save \"{}\": {error}", - path.file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()) - ).as_ref()); + self.command_line.push_error_once( + crate::tf!( + "Unable to save \"{}\": {error}", + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) + ) + .as_ref(), + ); self.pending_save_failure = Some(crate::app::PendingSaveFailure { tab_id, path, @@ -1972,22 +1961,18 @@ pub(super) fn on_open_file(&mut self) -> Task { && i == self.active_tab && !self.thumbnail_capture_clean && self.main_window.is_some() - && crate::ui::wrap_bar::dropdown_bounds( - crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, - ) - .is_some() + && crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID) + .is_some() { - self.pending_native_thumbnail_save = Some( - crate::app::PendingNativeThumbnailSave { - tab_id, - path, - version, - purpose, - continuation, - set_current_path, - check_external_change, - }, - ); + self.pending_native_thumbnail_save = Some(crate::app::PendingNativeThumbnailSave { + tab_id, + path, + version, + purpose, + continuation, + set_current_path, + check_external_change, + }); self.thumbnail_capture_clean = true; return Task::none(); } @@ -1998,12 +1983,15 @@ pub(super) fn on_open_file(&mut self) -> Task { self.pending_save_leases.insert(tab_id, lease); } Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => { - self.command_line.push_error_once(crate::tf!( - "Unable to save \"{}\": {error}", - path.file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()) - ).as_ref()); + self.command_line.push_error_once( + crate::tf!( + "Unable to save \"{}\": {error}", + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) + ) + .as_ref(), + ); self.pending_save_failure = Some(crate::app::PendingSaveFailure { tab_id, path, @@ -2021,9 +2009,9 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::none(); } Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => { - self.command_line.push_info(crate::tf!( - "{error}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{error}. External-change checks remain active.").as_ref(), + ); } } } @@ -2034,9 +2022,7 @@ pub(super) fn on_open_file(&mut self) -> Task { let thumbnail = (purpose != crate::app::SavePurpose::Autosave && i == self.active_tab) .then_some(version >= acadrust::DxfVersion::AC1027); let capture_bounds = thumbnail.and_then(|_| { - crate::ui::wrap_bar::dropdown_bounds( - crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, - ) + crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID) }); let clone_started = iced::time::Instant::now(); let snapshot = self.tabs[i].scene.document.clone(); @@ -2056,8 +2042,7 @@ pub(super) fn on_open_file(&mut self) -> Task { let previous_autosave = (purpose != crate::app::SavePurpose::Autosave).then(|| self.autosave_target(i)); let backup = purpose != crate::app::SavePurpose::Autosave && self.backup_on_save; - let verification = if check_external_change - && purpose != crate::app::SavePurpose::Autosave + let verification = if check_external_change && purpose != crate::app::SavePurpose::Autosave { let expected = if set_current_path { None @@ -2071,11 +2056,7 @@ pub(super) fn on_open_file(&mut self) -> Task { expected, ) } else if destination_is_current { - Self::native_save_verification( - &path, - self.tabs[i].edit_lease.as_mut(), - expected, - ) + Self::native_save_verification(&path, self.tabs[i].edit_lease.as_mut(), expected) } else { Self::native_save_verification(&path, None, expected) } @@ -2132,7 +2113,9 @@ pub(super) fn on_open_file(&mut self) -> Task { verify_reader, path, previous_autosave, - ) = work.take().expect("save capture produced more than one result"); + ) = work + .take() + .expect("save capture produced more than one result"); Task::perform( async move { let (result, refreshed_preview) = std::thread::spawn(move || { @@ -2191,12 +2174,14 @@ pub(super) fn on_open_file(&mut self) -> Task { ) }; match capture_window { - Some(window) => iced::window::screenshot(window).map(Some).then(move |screenshot| { - Task::batch([ - Task::done(Message::ThumbnailCaptureFinished), - run_save(screenshot), - ]) - }), + Some(window) => iced::window::screenshot(window) + .map(Some) + .then(move |screenshot| { + Task::batch([ + Task::done(Message::ThumbnailCaptureFinished), + run_save(screenshot), + ]) + }), None => { self.thumbnail_capture_clean = false; run_save(None) @@ -2205,12 +2190,8 @@ pub(super) fn on_open_file(&mut self) -> Task { } #[cfg(not(target_arch = "wasm32"))] - pub(super) fn on_save_finished( - &mut self, - outcome: crate::app::SaveOutcome, - ) -> Task { - let latest = self.active_save_jobs.get(&outcome.tab_id).copied() - == Some(outcome.job_id); + pub(super) fn on_save_finished(&mut self, outcome: crate::app::SaveOutcome) -> Task { + let latest = self.active_save_jobs.get(&outcome.tab_id).copied() == Some(outcome.job_id); if latest { self.active_save_jobs.remove(&outcome.tab_id); } @@ -2230,17 +2211,16 @@ pub(super) fn on_open_file(&mut self) -> Task { } if let Err(error) = &outcome.result { - if error.externally_modified - && outcome.purpose != crate::app::SavePurpose::Autosave - { + if error.externally_modified && outcome.purpose != crate::app::SavePurpose::Autosave { let file_name = outcome .path .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| outcome.path.display().to_string()); - self.command_line.push_error_once(crate::tf!( - "Save stopped: \"{file_name}\" changed outside Open CAD Studio." - ).as_ref()); + self.command_line.push_error_once( + crate::tf!("Save stopped: \"{file_name}\" changed outside Open CAD Studio.") + .as_ref(), + ); self.pending_external_change = Some(crate::app::PendingExternalChange { tab_id: outcome.tab_id, path: outcome.path.clone(), @@ -2259,9 +2239,12 @@ pub(super) fn on_open_file(&mut self) -> Task { .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| outcome.path.display().to_string()); - self.command_line.push_error_once(crate::tf!( - "Unable to save \"{file_name}\": file is in use by another application." - ).as_ref()); + self.command_line.push_error_once( + crate::tf!( + "Unable to save \"{file_name}\": file is in use by another application." + ) + .as_ref(), + ); self.pending_save_failure = Some(crate::app::PendingSaveFailure { tab_id: outcome.tab_id, path: outcome.path.clone(), @@ -2309,16 +2292,15 @@ pub(super) fn on_open_file(&mut self) -> Task { let mut tasks = Vec::new(); match outcome.purpose { crate::app::SavePurpose::Autosave => { - self.command_line.push_output(crate::t!("Autosaved 1 drawing").as_ref()); + self.command_line + .push_output(crate::t!("Autosaved 1 drawing").as_ref()); } crate::app::SavePurpose::Manual | crate::app::SavePurpose::SaveAs => { let path_changed = outcome.set_current_path && self.tabs[i] .current_path .as_deref() - .is_none_or(|current| { - !native_paths_match(current, &outcome.path) - }); + .is_none_or(|current| !native_paths_match(current, &outcome.path)); self.command_line .push_output(crate::tf!("Saved: {}", outcome.path.display()).as_ref()); if let Some(previous) = outcome.previous_autosave { @@ -2384,9 +2366,7 @@ pub(super) fn on_open_file(&mut self) -> Task { ) { self.pending_close = match continuation { crate::app::SaveContinuation::None => None, - crate::app::SaveContinuation::CloseTab => { - Some(crate::app::PendingClose::Tab(tab_idx)) - } + crate::app::SaveContinuation::CloseTab => Some(crate::app::PendingClose::Tab(tab_idx)), crate::app::SaveContinuation::Quit => Some(crate::app::PendingClose::Quit), }; } @@ -2441,8 +2421,7 @@ pub(super) fn on_open_file(&mut self) -> Task { self.close_active_modal(); self.restore_failed_save_continuation(failure.continuation, i); self.active_tab = i; - self.save_dialog_for_unsaved = - failure.continuation != crate::app::SaveContinuation::None; + self.save_dialog_for_unsaved = failure.continuation != crate::app::SaveContinuation::None; self.open_save_dialog_window(i) } @@ -2484,8 +2463,7 @@ pub(super) fn on_open_file(&mut self) -> Task { self.close_active_modal(); self.restore_failed_save_continuation(conflict.continuation, i); self.active_tab = i; - self.save_dialog_for_unsaved = - conflict.continuation != crate::app::SaveContinuation::None; + self.save_dialog_for_unsaved = conflict.continuation != crate::app::SaveContinuation::None; self.open_save_dialog_window(i) } @@ -2521,40 +2499,41 @@ pub(super) fn on_open_file(&mut self) -> Task { } pub(super) fn on_save_file(&mut self) -> Task { - if self.read_only { - self.command_line - .push_error(crate::t!("Read-only session (--read-only): saving is disabled.").as_ref()); - return Task::none(); - } - let i = self.active_tab; - // Web serializes immediately below. Native preparation happens - // once after the destination/version is known. - #[cfg(target_arch = "wasm32")] - { - self.sync_view_state_for_save(i); - self.stamp_header_sysvars(i); - } - // Native: save straight to the known path. Web has no path - // (downloads instead), so always go through the Save dialog. - #[cfg(not(target_arch = "wasm32"))] - if !self.tabs[i].recovery_save_as_required { - if let Some(path) = self.tabs[i].current_path.clone() { - // A direct Save preserves the document's current version. - let ver = self.tabs[i].scene.document.version; - self.prepare_native_save(i); - return self.queue_native_save( - i, - path, - ver, - crate::app::SavePurpose::Manual, - crate::app::SaveContinuation::None, - false, - true, - ); - } - } - self.save_dialog_for_unsaved = false; - self.save_with_default_format(i) + if self.read_only { + self.command_line.push_error( + crate::t!("Read-only session (--read-only): saving is disabled.").as_ref(), + ); + return Task::none(); + } + let i = self.active_tab; + // Web serializes immediately below. Native preparation happens + // once after the destination/version is known. + #[cfg(target_arch = "wasm32")] + { + self.sync_view_state_for_save(i); + self.stamp_header_sysvars(i); + } + // Native: save straight to the known path. Web has no path + // (downloads instead), so always go through the Save dialog. + #[cfg(not(target_arch = "wasm32"))] + if !self.tabs[i].recovery_save_as_required { + if let Some(path) = self.tabs[i].current_path.clone() { + // A direct Save preserves the document's current version. + let ver = self.tabs[i].scene.document.version; + self.prepare_native_save(i); + return self.queue_native_save( + i, + path, + ver, + crate::app::SavePurpose::Manual, + crate::app::SaveContinuation::None, + false, + true, + ); + } + } + self.save_dialog_for_unsaved = false; + self.save_with_default_format(i) } /// Save without the version picker: use the configured default and go straight to @@ -2565,10 +2544,8 @@ pub(super) fn on_open_file(&mut self) -> Task { self.active_tab = tab_idx; self.save_dialog_format = if self.tabs[tab_idx].recovery_save_as_required { let document = &self.tabs[tab_idx].scene.document; - let is_dxf = crate::io::source_is_dxf( - self.tabs[tab_idx].current_path.as_deref(), - document, - ); + let is_dxf = + crate::io::source_is_dxf(self.tabs[tab_idx].current_path.as_deref(), document); let version = if is_dxf { document.version } else { @@ -2598,110 +2575,104 @@ pub(super) fn on_open_file(&mut self) -> Task { } pub(super) fn on_save_dialog_confirm(&mut self) -> Task { - let (ext, version) = crate::io::parse_save_format(&self.save_dialog_format); - // Warn before a lossy Save-As that would drop unsupported - // (AEC / application) objects kept only as verbatim - // source-version bytes — let the user keep them by saving in the - // source version, or proceed and drop them. - if !self.aec_drop_acknowledged { - let is_dxf = ext.eq_ignore_ascii_case("dxf"); - let n = crate::io::dropped_on_save_count( - &self.tabs[self.active_tab].scene.document, - version, - is_dxf, - ); - if n > 0 { - self.aec_drop_count = n; - self.active_modal = Some(crate::app::ModalKind::AecDropWarning); - return Task::none(); - } - } - // The user need not type an extension: append the selected - // format's one when the entered name carries none. - let name = self.save_dialog_filename.trim(); - let filename = if name.is_empty() { - format!("drawing.{ext}") - } else if std::path::Path::new(name).extension().is_none() { - format!("{name}.{ext}") - } else { - name.to_string() - }; - self.save_dialog_filename = filename.clone(); - let i = self.active_tab; + let (ext, version) = crate::io::parse_save_format(&self.save_dialog_format); + // Warn before a lossy Save-As that would drop unsupported + // (AEC / application) objects kept only as verbatim + // source-version bytes — let the user keep them by saving in the + // source version, or proceed and drop them. + if !self.aec_drop_acknowledged { + let is_dxf = ext.eq_ignore_ascii_case("dxf"); + let n = crate::io::dropped_on_save_count( + &self.tabs[self.active_tab].scene.document, + version, + is_dxf, + ); + if n > 0 { + self.aec_drop_count = n; + self.active_modal = Some(crate::app::ModalKind::AecDropWarning); + return Task::none(); + } + } + // The user need not type an extension: append the selected + // format's one when the entered name carries none. + let name = self.save_dialog_filename.trim(); + let filename = if name.is_empty() { + format!("drawing.{ext}") + } else if std::path::Path::new(name).extension().is_none() { + format!("{name}.{ext}") + } else { + name.to_string() + }; + self.save_dialog_filename = filename.clone(); + let i = self.active_tab; - // Native: hand the destination choice to the OS save dialog — - // it provides folder browsing and overwrite confirmation. The - // chosen version rides in `save_dialog_format` and the write - // happens in `on_save_dialog_path_picked` once a path returns. - #[cfg(not(target_arch = "wasm32"))] - { - let seed_dir = self.tabs[i] - .current_path - .as_ref() - .and_then(|p| p.parent().map(|d| d.to_path_buf())); - let default_name = filename; - let (filter_label, filter_ext): (&str, &str) = - if ext.eq_ignore_ascii_case("dxf") { - ("DXF Files", "dxf") - } else { - ("DWG Files", "dwg") - }; - let close = self.close_save_dialog_window(); - let pick = Task::perform( - async move { - let mut dlg = crate::sys::file_dialog() - .set_title(crate::t!("Save Drawing As").as_ref()) - .set_file_name(default_name) - .add_filter(filter_label, &[filter_ext]); - if let Some(dir) = seed_dir { - dlg = dlg.set_directory(dir); - } - dlg.save_file().await.map(|h| crate::sys::handle_path(&h)) - }, - Message::SaveDialogPathPicked, - ); - Task::batch([close, pick]) - } - // Web: no filesystem — serialize and hand the browser a download. - #[cfg(target_arch = "wasm32")] - { - let close = self.close_save_dialog_window(); - self.sync_view_state_for_save(i); - sync_annotation_scale_header(&mut self.tabs[i].scene); - self.stamp_header_sysvars(i); - self.sync_solid_models_for_save(i); - self.tabs[i].scene.materialize_sketch_constraints_for_save(); - self.tabs[i].scene.materialize_named_parameters_for_save(); - self.tabs[i].scene.materialize_dwg_native_constraints_for_save(self.write_dwg_native_constraints); - let tab_id = self.tabs[i].id; - let bounds = crate::ui::wrap_bar::dropdown_bounds( - crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, - ); - let Some(bounds) = bounds else { - return Task::batch([ - close, - Task::done(Message::WebSaveScreenshot { - tab_id, - filename, - ext: ext.to_string(), - version, - bounds: None, - screenshot: None, - }), - ]); - }; - self.pending_web_thumbnail_save = Some( - crate::app::PendingWebThumbnailSave { - tab_id, - filename, - ext: ext.to_string(), - version, - bounds, - }, - ); - self.thumbnail_capture_clean = true; - close - } + // Native: hand the destination choice to the OS save dialog — + // it provides folder browsing and overwrite confirmation. The + // chosen version rides in `save_dialog_format` and the write + // happens in `on_save_dialog_path_picked` once a path returns. + #[cfg(not(target_arch = "wasm32"))] + { + let seed_dir = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|d| d.to_path_buf())); + let default_name = filename; + let (filter_label, filter_ext): (&str, &str) = if ext.eq_ignore_ascii_case("dxf") { + ("DXF Files", "dxf") + } else { + ("DWG Files", "dwg") + }; + let close = self.close_save_dialog_window(); + let pick = Task::perform( + async move { + let mut dlg = crate::sys::file_dialog() + .set_title(crate::t!("Save Drawing As").as_ref()) + .set_file_name(default_name) + .add_filter(filter_label, &[filter_ext]); + if let Some(dir) = seed_dir { + dlg = dlg.set_directory(dir); + } + dlg.save_file().await.map(|h| crate::sys::handle_path(&h)) + }, + Message::SaveDialogPathPicked, + ); + Task::batch([close, pick]) + } + // Web: no filesystem — serialize and hand the browser a download. + #[cfg(target_arch = "wasm32")] + { + let close = self.close_save_dialog_window(); + self.sync_view_state_for_save(i); + sync_annotation_scale_header(&mut self.tabs[i].scene); + self.stamp_header_sysvars(i); + self.sync_solid_models_for_save(i); + self.tabs[i].scene.sync_native_parametric_graph(); + let tab_id = self.tabs[i].id; + let bounds = + crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID); + let Some(bounds) = bounds else { + return Task::batch([ + close, + Task::done(Message::WebSaveScreenshot { + tab_id, + filename, + ext: ext.to_string(), + version, + bounds: None, + screenshot: None, + }), + ]); + }; + self.pending_web_thumbnail_save = Some(crate::app::PendingWebThumbnailSave { + tab_id, + filename, + ext: ext.to_string(), + version, + bounds, + }); + self.thumbnail_capture_clean = true; + close + } } /// Native: a destination path came back from the OS save dialog — write the @@ -2790,10 +2761,7 @@ pub(super) fn on_open_file(&mut self) -> Task { .chars() .map(|c| if c.is_alphanumeric() { c } else { '_' }) .collect(); - std::env::temp_dir().join(format!( - "OpenCADStudio_{safe}_{}.sv$", - self.tabs[i].id - )) + std::env::temp_dir().join(format!("OpenCADStudio_{safe}_{}.sv$", self.tabs[i].id)) } } } @@ -2806,9 +2774,7 @@ pub(super) fn on_open_file(&mut self) -> Task { pub(super) fn on_autosave(&mut self) -> Task { let mut tasks = Vec::new(); for i in 0..self.tabs.len() { - if !self.tabs[i].dirty - || self.active_save_jobs.contains_key(&self.tabs[i].id) - { + if !self.tabs[i].dirty || self.active_save_jobs.contains_key(&self.tabs[i].id) { continue; } self.prepare_native_save(i); @@ -2888,10 +2854,7 @@ pub(super) fn on_open_file(&mut self) -> Task { area if area.starts_with("View: ") => PlotType::View, _ => PlotType::Layout, }; - ps.plot_view_name = plot_area - .strip_prefix("View: ") - .unwrap_or("") - .to_string(); + ps.plot_view_name = plot_area.strip_prefix("View: ").unwrap_or("").to_string(); if plot_area == "Window" { if let Some((x0, y0, x1, y1)) = plot_window { ps.set_plot_window(x0, y0, x1, y1); @@ -2961,10 +2924,13 @@ pub(super) fn on_open_file(&mut self) -> Task { self.tabs[i].dirty = true; self.tabs[i].scene.bump_geometry_no_blocks(); - self.command_line.push_info(crate::tf!( - "Page setup: {w:.1}×{h:.1} mm area={plot_area} \ + self.command_line.push_info( + crate::tf!( + "Page setup: {w:.1}×{h:.1} mm area={plot_area} \ center={center} rot={rotation}°" - ).as_ref()); + ) + .as_ref(), + ); } } @@ -2985,11 +2951,23 @@ pub(super) fn on_open_file(&mut self) -> Task { self.load_plotsettings_into_dialog(&settings); } } - let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = - self.direct_plot_params() + let Some(( + wires, + hatches, + wipeouts, + group_splits, + page_w, + page_h, + ox, + oy, + rotation, + scale, + clip, + )) = self.direct_plot_params() else { - self.command_line - .push_error(crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref()); + self.command_line.push_error( + crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref(), + ); return Task::none(); }; let plot_style = self.dialog_plot_style(&self.plot_dialog); @@ -2997,22 +2975,22 @@ pub(super) fn on_open_file(&mut self) -> Task { let worker_path = path.clone(); let work = move || { crate::io::pdf_export::export_pdf( - &wires, - &hatches, - &wipeouts, - page_w, - page_h, - ox, - oy, - rotation, - scale, - clip, - &worker_path, - plot_style.as_ref(), - render_options, - ) - .map(|_| crate::tf!("Exported: {}", worker_path.display()).into_owned()) - .map_err(|e| crate::tf!("Export failed: {e}").into_owned()) + &wires, + &hatches, + &wipeouts, + page_w, + page_h, + ox, + oy, + rotation, + scale, + clip, + &worker_path, + plot_style.as_ref(), + render_options, + ) + .map(|_| crate::tf!("Exported: {}", worker_path.display()).into_owned()) + .map_err(|e| crate::tf!("Export failed: {e}").into_owned()) }; self.run_plot_work(self.plot_dialog.background, false, work) } @@ -3028,8 +3006,19 @@ pub(super) fn on_open_file(&mut self) -> Task { "Extents" => self.extents_plot_job(), _ => self.window_plot_job(), }; - let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = - job + let Some(( + wires, + hatches, + wipeouts, + group_splits, + page_w, + page_h, + ox, + oy, + rotation, + scale, + clip, + )) = job else { self.command_line .push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref()); @@ -3041,35 +3030,36 @@ pub(super) fn on_open_file(&mut self) -> Task { self.close_active_modal(); let work = move || { crate::io::pdf_export::export_pdf( - &wires, - &hatches, - &wipeouts, - page_w, - page_h, - ox, - oy, - rotation, - scale, - clip, - &worker_path, - plot_style.as_ref(), - render_options, + &wires, + &hatches, + &wipeouts, + page_w, + page_h, + ox, + oy, + rotation, + scale, + clip, + &worker_path, + plot_style.as_ref(), + render_options, + ) + .map(|_| { + crate::tf!( + "Plotted window to {}", + worker_path + .file_name() + .unwrap_or_default() + .to_string_lossy() ) - .map(|_| {crate::tf!( - "Plotted window to {}", - worker_path - .file_name().unwrap_or_default().to_string_lossy() - ).into_owned() - }).map_err(|e| crate::tf!("Plot failed: {e}").into_owned()) + .into_owned() + }) + .map_err(|e| crate::tf!("Plot failed: {e}").into_owned()) }; self.run_plot_work(self.plot_dialog.background, false, work) } - pub(super) fn report_plot_result( - &mut self, - result: Result, - reopen_plot: bool, - ) { + pub(super) fn report_plot_result(&mut self, result: Result, reopen_plot: bool) { match result { Ok(message) => self.command_line.push_info(&message), Err(error) => self.command_line.push_error(&error), @@ -3146,19 +3136,19 @@ pub(super) fn on_open_file(&mut self) -> Task { .document .header .paper_space_linetype_scaling; - let original_plimcheck = self.tabs[i] - .scene - .document - .header - .paper_space_limit_check; + let original_plimcheck = self.tabs[i].scene.document.header.paper_space_limit_check; let original_dialog = self.plot_dialog.clone(); let original_style = self.active_plot_style.clone(); let original_window = self.plot_window; let original_setup = self.plot_setup_template.clone(); let original_camera = self.tabs[i].scene.camera.borrow().clone(); let original_camera_generation = self.tabs[i].scene.camera_generation; - let override_dialog = self.print_all_settings_override.then(|| original_dialog.clone()); - let override_style = self.print_all_settings_override.then(|| original_style.clone()); + let override_dialog = self + .print_all_settings_override + .then(|| original_dialog.clone()); + let override_style = self + .print_all_settings_override + .then(|| original_style.clone()); let result = (|| { let mut pages = Vec::with_capacity(selected.len()); for name in selected { @@ -3189,7 +3179,8 @@ pub(super) fn on_open_file(&mut self) -> Task { return Err(crate::tf!( "Layout '{name}' plot style table '{}' is not loaded.", dialog.style_name - ).into_owned()); + ) + .into_owned()); } let plot_style = self.dialog_plot_style(&dialog); let params = match dialog.area.as_str() { @@ -3229,7 +3220,9 @@ pub(super) fn on_open_file(&mut self) -> Task { rotation_deg, scale, clip, - ) = params.ok_or_else(|| crate::tf!("Layout '{name}' plot area is empty.").into_owned())?; + ) = params.ok_or_else(|| { + crate::tf!("Layout '{name}' plot area is empty.").into_owned() + })?; ( std::sync::Arc::new(wires), hatches, @@ -3268,11 +3261,7 @@ pub(super) fn on_open_file(&mut self) -> Task { .document .header .paper_space_linetype_scaling = original_psltscale; - self.tabs[i] - .scene - .document - .header - .paper_space_limit_check = original_plimcheck; + self.tabs[i].scene.document.header.paper_space_limit_check = original_plimcheck; self.plot_dialog = original_dialog; self.active_plot_style = original_style; self.plot_window = original_window; @@ -3282,19 +3271,12 @@ pub(super) fn on_open_file(&mut self) -> Task { result } - pub(super) fn on_print_all_pdf_path_some( - &mut self, - path: std::path::PathBuf, - ) -> Task { + pub(super) fn on_print_all_pdf_path_some(&mut self, path: std::path::PathBuf) -> Task { let dialog = self.plot_dialog.clone(); - if self.print_all_settings_override - && dialog.style_missing - && dialog.apply_plot_styles - { - self.command_line.push_error(crate::tf!( - "Plot style table '{}' is not loaded.", - dialog.style_name - ).as_ref()); + if self.print_all_settings_override && dialog.style_missing && dialog.apply_plot_styles { + self.command_line.push_error( + crate::tf!("Plot style table '{}' is not loaded.", dialog.style_name).as_ref(), + ); return Task::none(); } let pages = match self.print_all_pages() { @@ -3308,13 +3290,16 @@ pub(super) fn on_open_file(&mut self) -> Task { self.save_config(); self.close_active_modal(); let work = move || { - crate::io::pdf_export::export_pdf_pages( - &pages, - &worker_path, - None, - ) - .map(|_| crate::tf!("Exported {} layouts to {}", pages.len(), worker_path.display()).into_owned()) - .map_err(|error| crate::tf!("Export failed: {error}").into_owned()) + crate::io::pdf_export::export_pdf_pages(&pages, &worker_path, None) + .map(|_| { + crate::tf!( + "Exported {} layouts to {}", + pages.len(), + worker_path.display() + ) + .into_owned() + }) + .map_err(|error| crate::tf!("Export failed: {error}").into_owned()) }; self.run_print_all_work(dialog.background, work) } @@ -3322,22 +3307,18 @@ pub(super) fn on_open_file(&mut self) -> Task { pub(super) fn on_print_all_print(&mut self) -> Task { #[cfg(target_arch = "wasm32")] { - self.command_line.push_error( - crate::t!("Printing is not available in the web version.").as_ref(), - ); + self.command_line + .push_error(crate::t!("Printing is not available in the web version.").as_ref()); Task::none() } #[cfg(not(target_arch = "wasm32"))] { let dialog = self.plot_dialog.clone(); - if self.print_all_settings_override - && dialog.style_missing - && dialog.apply_plot_styles + if self.print_all_settings_override && dialog.style_missing && dialog.apply_plot_styles { - self.command_line.push_error(crate::tf!( - "Plot style table '{}' is not loaded.", - dialog.style_name - ).as_ref()); + self.command_line.push_error( + crate::tf!("Plot style table '{}' is not loaded.", dialog.style_name).as_ref(), + ); return Task::none(); } let pages = match self.print_all_pages() { @@ -3351,20 +3332,18 @@ pub(super) fn on_open_file(&mut self) -> Task { let temp_path = crate::io::print_to_printer::temp_pdf_path("print_all"); self.save_config(); self.close_active_modal(); - self.command_line.push_info( - crate::t!("Sending selected layouts to the system printer…").as_ref(), - ); + self.command_line + .push_info(crate::t!("Sending selected layouts to the system printer…").as_ref()); let work = move || { - crate::io::pdf_export::export_pdf_pages( - &pages, - &temp_path, - None, - ) - .and_then(|_| { - crate::io::print_to_printer::print_existing_pdf(&temp_path, &options) - }) - .map(|printer| crate::tf!("Sent {} layouts to printer: {printer}", pages.len()).into_owned()) - .map_err(|error| crate::tf!("Print failed: {error}").into_owned()) + crate::io::pdf_export::export_pdf_pages(&pages, &temp_path, None) + .and_then(|_| { + crate::io::print_to_printer::print_existing_pdf(&temp_path, &options) + }) + .map(|printer| { + crate::tf!("Sent {} layouts to printer: {printer}", pages.len()) + .into_owned() + }) + .map_err(|error| crate::tf!("Print failed: {error}").into_owned()) }; self.run_print_all_work(true, work) } @@ -3381,12 +3360,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } } - fn run_plot_work( - &mut self, - background: bool, - reopen_plot: bool, - work: F, - ) -> Task + fn run_plot_work(&mut self, background: bool, reopen_plot: bool, work: F) -> Task where F: FnOnce() -> Result + Send + 'static, { @@ -3414,12 +3388,11 @@ pub(super) fn on_open_file(&mut self) -> Task { let scene = &self.tabs[i].scene; let paper_space = scene.current_layout != "Model"; let selected_sheet = plot_dialog_sheet_mm(&self.plot_dialog); - let (source_wires, hatches, wipeouts, mut group_splits) = - plot_scene_content( - scene, - self.plot_dialog.paperspace_last, - plot_render_mode_override(&self.plot_dialog), - ); + let (source_wires, hatches, wipeouts, mut group_splits) = plot_scene_content( + scene, + self.plot_dialog.paperspace_last, + plot_render_mode_override(&self.plot_dialog), + ); // The printable-area rectangle is an on-screen guide, not drawing // content. It used to leak into every paper-space PDF/preview/print. let wires = if paper_space { @@ -3456,21 +3429,12 @@ pub(super) fn on_open_file(&mut self) -> Task { // Only the explicit upside-down choice rotates layout content here. let rotation = dialog_rotation; let centered = self.plot_dialog.center; - let origin_x = self - .plot_dialog - .offset_x - .parse::() - .unwrap_or(0.0); - let origin_y = self - .plot_dialog - .offset_y - .parse::() - .unwrap_or(0.0); + let origin_x = self.plot_dialog.offset_x.parse::().unwrap_or(0.0); + let origin_y = self.plot_dialog.offset_y.parse::().unwrap_or(0.0); let (scale, offset_x, offset_y) = if plot_extents { let (min_x, min_y, max_x, max_y) = - plot_content_extents(&wires, &hatches, &wipeouts) - .unwrap_or((x0, y0, x1, y1)); + plot_content_extents(&wires, &hatches, &wipeouts).unwrap_or((x0, y0, x1, y1)); let content_w = (max_x - min_x).max(1e-9); let content_h = (max_y - min_y).max(1e-9); let scale = if self.plot_dialog.fit_to_paper { @@ -3491,11 +3455,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } else { origin_y }; - ( - scale, - target_x / scale - min_x, - target_y / scale - min_y, - ) + (scale, target_x / scale - min_x, target_y / scale - min_y) } else if plot_layout { // Map the complete paper-space sheet to the physical paper // selected in the dialog. Layout bounds can be stored in mm, @@ -3510,11 +3470,7 @@ pub(super) fn on_open_file(&mut self) -> Task { .max(1e-9); let target_x = (selected_sheet.0 - bounds_w * scale) * 0.5; let target_y = (selected_sheet.1 - bounds_h * scale) * 0.5; - ( - scale, - target_x / scale - x0, - target_y / scale - y0, - ) + (scale, target_x / scale - x0, target_y / scale - y0) } else { // Legacy direct callers still get dialog positioning. Normal // Display/Window paths use `area_plot_job` instead. @@ -3544,14 +3500,16 @@ pub(super) fn on_open_file(&mut self) -> Task { // ink is restricted to the device's printable rectangle. Extents // has its own fitted area and must not inherit this paper clip. let clip = if plot_layout { - scene.printable_area_limits().map(|((px0, py0), (px1, py1))| { - ( - (px0 + offset_x) as f32, - (py0 + offset_y) as f32, - (px1 - px0) as f32, - (py1 - py0) as f32, - ) - }) + scene + .printable_area_limits() + .map(|((px0, py0), (px1, py1))| { + ( + (px0 + offset_x) as f32, + (py0 + offset_y) as f32, + (px1 - px0) as f32, + (py1 - py0) as f32, + ) + }) } else { None }; @@ -3629,20 +3587,32 @@ pub(super) fn on_open_file(&mut self) -> Task { } pub(super) fn on_print_to_printer(&mut self) -> Task { - let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = - self.direct_plot_params() + let Some(( + wires, + hatches, + wipeouts, + group_splits, + page_w, + page_h, + ox, + oy, + rotation, + scale, + clip, + )) = self.direct_plot_params() else { - self.command_line - .push_error(crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref()); + self.command_line.push_error( + crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref(), + ); return Task::none(); }; let plot_style = self.dialog_plot_style(&self.plot_dialog); let options = self.plot_print_options(&self.plot_dialog, group_splits); - self.command_line.push_info(crate::t!("Sending to system printer…").as_ref()); + self.command_line + .push_info(crate::t!("Sending to system printer…").as_ref()); background_task( move || { - iced::futures::executor::block_on( - crate::io::print_to_printer::print_wires_with( + iced::futures::executor::block_on(crate::io::print_to_printer::print_wires_with( wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, plot_style, options, )) @@ -3882,7 +3852,9 @@ pub(super) fn on_open_file(&mut self) -> Task { match crate::io::print_to_printer::open_printer_properties( self.plot_dialog.printer.as_deref(), ) { - Ok(()) => self.command_line.push_info(crate::t!("Opened printer properties.").as_ref()), + Ok(()) => self + .command_line + .push_info(crate::t!("Opened printer properties.").as_ref()), Err(error) => self.command_line.push_error(&error), } Task::none() @@ -3918,11 +3890,8 @@ pub(super) fn on_open_file(&mut self) -> Task { self.plot_dialog.offset_x = "0.0".into(); self.plot_dialog.offset_y = "0.0".into(); self.plot_dialog.fit_to_paper = false; - self.plot_dialog.scale = scale_name_for_factor( - &self.plot_dialog.scales, - 1.0, - ) - .unwrap_or_else(|| "1:1".into()); + self.plot_dialog.scale = scale_name_for_factor(&self.plot_dialog.scales, 1.0) + .unwrap_or_else(|| "1:1".into()); } self.plot_dialog.area = s; Task::none() @@ -3965,9 +3934,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } } PlotFlag::Center if d.area != "Layout" => d.center = !d.center, - PlotFlag::ScaleLw if !d.fit_to_paper => { - d.scale_lw = !d.scale_lw - } + PlotFlag::ScaleLw if !d.fit_to_paper => d.scale_lw = !d.scale_lw, PlotFlag::PlotStyles if !d.style_name.is_empty() => { d.apply_plot_styles = !d.apply_plot_styles } @@ -4043,9 +4010,10 @@ pub(super) fn on_open_file(&mut self) -> Task { } Task::none() } - M::SetCurrent => { + M::SetCurrent => { self.apply_dialog_to_layout(); - self.command_line.push_info(crate::t!("Page setup applied to the layout.").as_ref()); + self.command_line + .push_info(crate::t!("Page setup applied to the layout.").as_ref()); Task::none() } M::NewSetup => { @@ -4128,7 +4096,9 @@ pub(super) fn on_open_file(&mut self) -> Task { if !name.is_empty() { if self.plot_dialog.name_rename { let old = self.plot_dialog.selected_setup.clone(); - self.tabs[self.active_tab].scene.page_setup_rename(&old, &name); + self.tabs[self.active_tab] + .scene + .page_setup_rename(&old, &name); } else { let ps = self.dialog_to_plotsettings(); self.tabs[self.active_tab].scene.page_setup_save(&name, ps); @@ -4149,10 +4119,13 @@ pub(super) fn on_open_file(&mut self) -> Task { M::Preview => self.on_plot_dlg_commit(true), M::Commit if self.print_all_options => { if self.plot_dialog.style_missing && self.plot_dialog.apply_plot_styles { - self.command_line.push_error(crate::tf!( - "Plot style table '{}' is not loaded.", - self.plot_dialog.style_name - ).as_ref()); + self.command_line.push_error( + crate::tf!( + "Plot style table '{}' is not loaded.", + self.plot_dialog.style_name + ) + .as_ref(), + ); return Task::none(); } self.plot_dialog.paper_space = true; @@ -4201,7 +4174,11 @@ pub(super) fn on_open_file(&mut self) -> Task { d.orientation = "Landscape".into(); d.paper_width_mm = 297.0; d.paper_height_mm = 210.0; - d.area = if is_model { "Window".into() } else { "Layout".into() }; + d.area = if is_model { + "Window".into() + } else { + "Layout".into() + }; d.center = true; d.offset_x = "0.0".into(); d.offset_y = "0.0".into(); @@ -4216,8 +4193,7 @@ pub(super) fn on_open_file(&mut self) -> Task { self.plot_dialog.copy_settings_from(&prev); } } else if is_layout_entry(name) { - if let Some(ps) = self - .tabs[self.active_tab] + if let Some(ps) = self.tabs[self.active_tab] .scene .plot_settings_for(layout_entry_name(name)) { @@ -4269,11 +4245,7 @@ pub(super) fn on_open_file(&mut self) -> Task { area if area.starts_with("View: ") => PlotType::View, _ => PlotType::Extents, }; - ps.plot_view_name = d - .area - .strip_prefix("View: ") - .unwrap_or("") - .to_string(); + ps.plot_view_name = d.area.strip_prefix("View: ").unwrap_or("").to_string(); if d.area == "Window" { if let Some((x0, y0, x1, y1)) = self.plot_window { ps.set_plot_window(x0, y0, x1, y1); @@ -4339,9 +4311,7 @@ pub(super) fn on_open_file(&mut self) -> Task { /// Load a `PlotSettings` into the dialog editor fields. fn load_plotsettings_into_dialog(&mut self, ps: &acadrust::objects::PlotSettings) { - use acadrust::objects::{ - PlotType, ShadePlotMode, ShadePlotResolutionLevel, - }; + use acadrust::objects::{PlotType, ShadePlotMode, ShadePlotResolutionLevel}; self.plot_setup_template = Some(ps.clone()); if matches!(ps.plot_type, PlotType::Window) && !ps.plot_window.is_empty() { self.plot_window = Some(( @@ -4353,17 +4323,20 @@ pub(super) fn on_open_file(&mut self) -> Task { } if !ps.current_style_sheet.is_empty() && self - .active_plot_style - .as_ref() - .is_none_or(|table| !table.name.eq_ignore_ascii_case(&ps.current_style_sheet)) + .active_plot_style + .as_ref() + .is_none_or(|table| !table.name.eq_ignore_ascii_case(&ps.current_style_sheet)) { - if let Ok(table) = crate::io::plot_style::PlotStyleTable::load_named( - &ps.current_style_sheet, - ) { + if let Ok(table) = + crate::io::plot_style::PlotStyleTable::load_named(&ps.current_style_sheet) + { self.active_plot_style = Some(table); } } - let active_style_name = self.active_plot_style.as_ref().map(|table| table.name.clone()); + let active_style_name = self + .active_plot_style + .as_ref() + .map(|table| table.name.clone()); let style_name = if ps.current_style_sheet.is_empty() { String::new() } else if active_style_name @@ -4382,8 +4355,7 @@ pub(super) fn on_open_file(&mut self) -> Task { if !matches!( paper.as_str(), "A4" | "A3" | "A2" | "A1" | "A0" | "Letter" | "Legal" | "Tabloid" - ) - && !ps.paper_size.is_empty() + ) && !ps.paper_size.is_empty() { paper = ps.paper_size.clone(); } @@ -4507,25 +4479,16 @@ pub(super) fn on_open_file(&mut self) -> Task { let rotation: i16 = if d.upside_down { 180 } else { 0 }; let off_x = d.offset_x.parse::().unwrap_or(0.0); let off_y = d.offset_y.parse::().unwrap_or(0.0); - self.apply_plot_page_settings( - sheet_w, - sheet_h, - &d.area, - d.center, - off_x, - off_y, - rotation, - ); + self.apply_plot_page_settings(sheet_w, sheet_h, &d.area, d.center, off_x, off_y, rotation); } /// Open a preview PDF, export a PDF, or send the job to the chosen printer. fn on_plot_dlg_commit(&mut self, preview: bool) -> Task { let d = self.plot_dialog.clone(); if d.style_missing && d.apply_plot_styles { - self.command_line.push_error(crate::tf!( - "Plot style table '{}' is not loaded.", - d.style_name - ).as_ref()); + self.command_line.push_error( + crate::tf!("Plot style table '{}' is not loaded.", d.style_name).as_ref(), + ); return Task::none(); } // Remember the user's print preferences across sessions. @@ -4576,11 +4539,23 @@ pub(super) fn on_open_file(&mut self) -> Task { let render_options = Self::pdf_plot_options(&d, wgroup_splits); let work = move || { crate::io::pdf_export::export_pdf( - &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp, - plot_style.as_ref(), - render_options, - ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) - .map(|_| crate::t!("Opened plot preview.").into_owned()).map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) + &w_wires, + &w_hatches, + &w_wipeouts, + sw, + sh, + wox, + woy, + wrotation, + wscale, + wclip, + &tmp, + plot_style.as_ref(), + render_options, + ) + .and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) + .map(|_| crate::t!("Opened plot preview.").into_owned()) + .map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) }; return self.run_plot_work(d.background, true, work); } @@ -4593,12 +4568,23 @@ pub(super) fn on_open_file(&mut self) -> Task { let opts = self.plot_print_options(&d, wgroup_splits); let work = move || { crate::io::pdf_export::export_pdf( - &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp, - plot_style.as_ref(), - render_options, - ).and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)) - .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) - .map_err(|e| crate::tf!("Print failed: {e}").into_owned()) + &w_wires, + &w_hatches, + &w_wipeouts, + sw, + sh, + wox, + woy, + wrotation, + wscale, + wclip, + &tmp, + plot_style.as_ref(), + render_options, + ) + .and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)) + .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) + .map_err(|e| crate::tf!("Print failed: {e}").into_owned()) }; return self.run_plot_work(true, false, work); } @@ -4611,21 +4597,23 @@ pub(super) fn on_open_file(&mut self) -> Task { let render_options = Self::pdf_plot_options(&d, group_splits); let work = move || { crate::io::pdf_export::export_pdf( - &wires, - &hatches, - &wipeouts, - page_w, - page_h, - ox, - oy, - rotation, - scale, - clip, - &tmp, - plot_style.as_ref(), - render_options, - ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) - .map(|_| crate::t!("Opened plot preview.").into_owned()).map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) + &wires, + &hatches, + &wipeouts, + page_w, + page_h, + ox, + oy, + rotation, + scale, + clip, + &tmp, + plot_style.as_ref(), + render_options, + ) + .and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) + .map(|_| crate::t!("Opened plot preview.").into_owned()) + .map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) }; return self.run_plot_work(d.background, true, work); } @@ -4636,15 +4624,15 @@ pub(super) fn on_open_file(&mut self) -> Task { } let opts = self.plot_print_options(&d, group_splits); - self.command_line.push_info(crate::t!("Sending to system printer…").as_ref()); + self.command_line + .push_info(crate::t!("Sending to system printer…").as_ref()); let work = move || { - iced::futures::executor::block_on( - crate::io::print_to_printer::print_wires_with( - wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, - plot_style, opts, - )) - .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) - .map_err(|error| crate::tf!("Print failed: {error}").into_owned()) + iced::futures::executor::block_on(crate::io::print_to_printer::print_wires_with( + wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, + plot_style, opts, + )) + .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) + .map_err(|error| crate::tf!("Print failed: {error}").into_owned()) }; self.run_plot_work(true, false, work) } @@ -4700,9 +4688,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } fn limits_plot_job(&self) -> Option { - let (min, max) = self.tabs[self.active_tab] - .scene - .current_drawing_limits()?; + let (min, max) = self.tabs[self.active_tab].scene.current_drawing_limits()?; self.area_plot_job((min.x, min.y, max.x, max.y)) } @@ -4714,7 +4700,8 @@ pub(super) fn on_open_file(&mut self) -> Task { .iter() .find(|view| { view.name.eq_ignore_ascii_case(name) - && view.paper_space == (self.tabs[self.active_tab].scene.current_layout != "Model") + && view.paper_space + == (self.tabs[self.active_tab].scene.current_layout != "Model") })?; let half_w = view.width.abs() * 0.5; let half_h = view.height.abs() * 0.5; @@ -4731,19 +4718,13 @@ pub(super) fn on_open_file(&mut self) -> Task { let scene = &self.tabs[self.active_tab].scene; if scene.current_layout == "Model" { let (min, max) = scene.model_space_extents()?; - return self.area_plot_job(( - min.x as f64, - min.y as f64, - max.x as f64, - max.y as f64, - )); + return self.area_plot_job((min.x as f64, min.y as f64, max.x as f64, max.y as f64)); } - let (wires, hatches, wipeouts, _) = - plot_scene_content( - scene, - self.plot_dialog.paperspace_last, - plot_render_mode_override(&self.plot_dialog), - ); + let (wires, hatches, wipeouts, _) = plot_scene_content( + scene, + self.plot_dialog.paperspace_last, + plot_render_mode_override(&self.plot_dialog), + ); let extents = plot_content_extents( &wires .iter() @@ -4792,12 +4773,7 @@ pub(super) fn on_open_file(&mut self) -> Task { iced::Point::new(bounds.width, bounds.height), iced::Point::new(0.0, bounds.height), ] { - let world = camera.unproject_on_plane( - point, - bounds, - glam::Vec3::Z, - glam::DVec3::ZERO, - ); + let world = camera.unproject_on_plane(point, bounds, glam::Vec3::Z, glam::DVec3::ZERO); x0 = x0.min(world.x); y0 = y0.min(world.y); x1 = x1.max(world.x); @@ -4853,21 +4829,24 @@ pub(super) fn on_open_file(&mut self) -> Task { }; let scene = &self.tabs[i].scene; let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); - let (all_wires, hatches, wipeouts, mut group_splits) = - plot_scene_content( - scene, - self.plot_dialog.paperspace_last, - plot_render_mode_override(&self.plot_dialog), - ); + let (all_wires, hatches, wipeouts, mut group_splits) = plot_scene_content( + scene, + self.plot_dialog.paperspace_last, + plot_render_mode_override(&self.plot_dialog), + ); let first_wire_count = all_wires[..group_splits.wires.min(all_wires.len())] .iter() .filter(|w| w.name != "paper_printable_area") - .filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0) + .filter(|w| { + w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0 + }) .count(); let wires: Vec<_> = all_wires .iter() .filter(|w| w.name != "paper_printable_area") - .filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0) + .filter(|w| { + w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0 + }) .cloned() .collect(); group_splits.wires = first_wire_count; @@ -4900,79 +4879,81 @@ pub(super) fn on_open_file(&mut self) -> Task { } pub(super) fn on_plot_style_panel_apply(&mut self) -> Task { - let aci = self.plotstyle_panel_aci as usize; - if let Some(table) = self.active_plot_style.as_mut() { - if let Some(entry) = table.aci_entries.get_mut(aci) { - // Parse color. - let color_str = self.ps_color_buf.trim(); - if color_str.is_empty() { - entry.color = None; - } else if color_str.starts_with('#') && color_str.len() == 7 { - let r = u8::from_str_radix(&color_str[1..3], 16).unwrap_or(0); - let g = u8::from_str_radix(&color_str[3..5], 16).unwrap_or(0); - let b = u8::from_str_radix(&color_str[5..7], 16).unwrap_or(0); - entry.color = Some([r, g, b]); - } - if let Ok(lw) = self.ps_lineweight_buf.trim().parse::() { - entry.lineweight = lw; - } - if let Ok(sc) = self.ps_screening_buf.trim().parse::() { - entry.screening = sc.min(100); - } - - } - } else { - // No table loaded: create an identity table and apply. - let mut table = crate::io::plot_style::PlotStyleTable::identity("Custom.ctb"); - if let Some(entry) = table.aci_entries.get_mut(aci) { - let color_str = self.ps_color_buf.trim(); - if color_str.starts_with('#') && color_str.len() == 7 { - let r = u8::from_str_radix(&color_str[1..3], 16).unwrap_or(0); - let g = u8::from_str_radix(&color_str[3..5], 16).unwrap_or(0); - let b = u8::from_str_radix(&color_str[5..7], 16).unwrap_or(0); - entry.color = Some([r, g, b]); - } - if let Ok(lw) = self.ps_lineweight_buf.trim().parse::() { - entry.lineweight = lw; - } - if let Ok(sc) = self.ps_screening_buf.trim().parse::() { - entry.screening = sc.min(100); - } - } - self.active_plot_style = Some(table); - self.command_line - .push_output(crate::tf!("Created new CTB table, ACI {aci} updated.").as_ref()); + let aci = self.plotstyle_panel_aci as usize; + if let Some(table) = self.active_plot_style.as_mut() { + if let Some(entry) = table.aci_entries.get_mut(aci) { + // Parse color. + let color_str = self.ps_color_buf.trim(); + if color_str.is_empty() { + entry.color = None; + } else if color_str.starts_with('#') && color_str.len() == 7 { + let r = u8::from_str_radix(&color_str[1..3], 16).unwrap_or(0); + let g = u8::from_str_radix(&color_str[3..5], 16).unwrap_or(0); + let b = u8::from_str_radix(&color_str[5..7], 16).unwrap_or(0); + entry.color = Some([r, g, b]); } - Task::none() + if let Ok(lw) = self.ps_lineweight_buf.trim().parse::() { + entry.lineweight = lw; + } + if let Ok(sc) = self.ps_screening_buf.trim().parse::() { + entry.screening = sc.min(100); + } + } + } else { + // No table loaded: create an identity table and apply. + let mut table = crate::io::plot_style::PlotStyleTable::identity("Custom.ctb"); + if let Some(entry) = table.aci_entries.get_mut(aci) { + let color_str = self.ps_color_buf.trim(); + if color_str.starts_with('#') && color_str.len() == 7 { + let r = u8::from_str_radix(&color_str[1..3], 16).unwrap_or(0); + let g = u8::from_str_radix(&color_str[3..5], 16).unwrap_or(0); + let b = u8::from_str_radix(&color_str[5..7], 16).unwrap_or(0); + entry.color = Some([r, g, b]); + } + if let Ok(lw) = self.ps_lineweight_buf.trim().parse::() { + entry.lineweight = lw; + } + if let Ok(sc) = self.ps_screening_buf.trim().parse::() { + entry.screening = sc.min(100); + } + } + self.active_plot_style = Some(table); + self.command_line + .push_output(crate::tf!("Created new CTB table, ACI {aci} updated.").as_ref()); + } + Task::none() } pub(super) fn on_plot_style_panel_save(&mut self) -> Task { - if self.active_plot_style.is_none() { - self.command_line - .push_error(crate::t!("No plot style table loaded. Load or create one first.").as_ref()); - return Task::none(); - } - let default_name = self - .active_plot_style - .as_ref() - .map(|t| t.name.clone()) - .unwrap_or("export.ctb".into()); - Task::perform( - async move { - let dialog = crate::sys::file_dialog() - .set_title(crate::t!("Save Plot Style Table").as_ref()) - .set_file_name(&default_name) - .add_filter(crate::t!("Plot Style Files").as_ref(), &["ctb", "CTB"]) - .add_filter(crate::t!("All Files").as_ref(), &["*"]); - #[cfg(not(target_arch = "wasm32"))] - let dialog = match crate::io::plot_style::ensure_plot_styles_dir() { - Ok(dir) => dialog.set_directory(dir), - Err(_) => dialog, - }; - dialog.save_file().await - .map(|h| crate::sys::handle_path(&h)) - }, - Message::PlotStylePanelSavePath, - ) + if self.active_plot_style.is_none() { + self.command_line.push_error( + crate::t!("No plot style table loaded. Load or create one first.").as_ref(), + ); + return Task::none(); + } + let default_name = self + .active_plot_style + .as_ref() + .map(|t| t.name.clone()) + .unwrap_or("export.ctb".into()); + Task::perform( + async move { + let dialog = crate::sys::file_dialog() + .set_title(crate::t!("Save Plot Style Table").as_ref()) + .set_file_name(&default_name) + .add_filter(crate::t!("Plot Style Files").as_ref(), &["ctb", "CTB"]) + .add_filter(crate::t!("All Files").as_ref(), &["*"]); + #[cfg(not(target_arch = "wasm32"))] + let dialog = match crate::io::plot_style::ensure_plot_styles_dir() { + Ok(dir) => dialog.set_directory(dir), + Err(_) => dialog, + }; + dialog + .save_file() + .await + .map(|h| crate::sys::handle_path(&h)) + }, + Message::PlotStylePanelSavePath, + ) } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9cfbe5769..4d3919596 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -130,9 +130,7 @@ impl OpenCADStudio { // Closing it restores the parent Plot dialog instead of returning // to the drawing. if self.active_modal == Some(Plotstyle) { - if let Some((plot_offset, plot_resize)) = - self.plotstyle_parent_plot_geometry.take() - { + if let Some((plot_offset, plot_resize)) = self.plotstyle_parent_plot_geometry.take() { self.active_modal = Some(Plot); self.reset_modal_geometry(); @@ -347,10 +345,7 @@ impl OpenCADStudio { if let Some(started) = perf_started { let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; if elapsed_ms >= 5.0 { - crate::perf_record!( - "[perf] update {:>7.1}ms message={perf_label}", - elapsed_ms, - ); + crate::perf_record!("[perf] update {:>7.1}ms message={perf_label}", elapsed_ms,); } } #[cfg(target_arch = "wasm32")] @@ -379,14 +374,29 @@ impl OpenCADStudio { envelope.reply.send(response); task } - #[cfg(target_arch="wasm32")] - Message::PollWebControl => if let Some(request)=super::control::web_request(){self.update(Message::ControlRequest(request))}else{Task::none()}, - #[cfg(not(target_arch="wasm32"))] + #[cfg(target_arch = "wasm32")] + Message::PollWebControl => { + if let Some(request) = super::control::web_request() { + self.update(Message::ControlRequest(request)) + } else { + Task::none() + } + } + #[cfg(not(target_arch = "wasm32"))] Message::PollWebControl => Task::none(), Message::ControlStep(id, message) => self.control_step(id, *message), - Message::ControlTaskDone(id) => { self.control_task_done(&id); Task::none() } - Message::ControlScreenshot(path, screenshot) => { self.control_screenshot(path, screenshot); Task::none() } - Message::ControlToggle => { self.control.enabled = !self.control.enabled; Task::none() } + Message::ControlTaskDone(id) => { + self.control_task_done(&id); + Task::none() + } + Message::ControlScreenshot(path, screenshot) => { + self.control_screenshot(path, screenshot); + Task::none() + } + Message::ControlToggle => { + self.control.enabled = !self.control.enabled; + Task::none() + } // Drain plugin-to-host requests that arrived outside of a host call. // This runs on a periodic timer so long-lived plugin sessions such // as the Python REPL can mutate the document without requiring a @@ -407,7 +417,8 @@ impl OpenCADStudio { { continue; } - let prefixed = format!("[{} {}] {}", line.plugin_id, line.source, line.text); + let prefixed = + format!("[{} {}] {}", line.plugin_id, line.source, line.text); match line.source { ocs_plugin_api::process::IoStream::Stderr => { self.command_line.push_error(&prefixed); @@ -461,18 +472,14 @@ impl OpenCADStudio { return Task::none(); }; iced::font::load((*bytes).clone()).map(move |result| { - Message::WebUiFontLoaded( - script, - result.map_err(|error| format!("{error:?}")), - ) + Message::WebUiFontLoaded(script, result.map_err(|error| format!("{error:?}"))) }) } Message::WebUiFontLoaded(script, result) => { if let Err(error) = result { - self.command_line.push_error( - crate::tf!("Font load failed ({script:?}): {error}").as_ref(), - ); + self.command_line + .push_error(crate::tf!("Font load failed ({script:?}): {error}").as_ref()); return Task::none(); } if script == crate::scene::text::web_font::primary_script() { @@ -510,10 +517,10 @@ impl OpenCADStudio { return match std::fs::metadata(&path) { Ok(m) => self.update(Message::OpenPathPicked(Some((path, m.len())))), Err(_) => { - self.command_line.push_error(crate::tf!( - "Recent file no longer exists: {}", - path.display() - ).as_ref()); + self.command_line.push_error( + crate::tf!("Recent file no longer exists: {}", path.display()) + .as_ref(), + ); Task::none() } }; @@ -543,10 +550,9 @@ impl OpenCADStudio { recovery_read_stats: None, recovery_bytes: None, }); - Task::perform( - crate::io::open_recent_web(path, state), - move |outcome| Message::WebFileOpened(open_id, outcome), - ) + Task::perform(crate::io::open_recent_web(path, state), move |outcome| { + Message::WebFileOpened(open_id, outcome) + }) } } @@ -575,9 +581,7 @@ impl OpenCADStudio { ]), None => Task::none(), }; - if self.opening.is_some() - || self.active_modal == Some(super::ModalKind::Recovery) - { + if self.opening.is_some() || self.active_modal == Some(super::ModalKind::Recovery) { self.pending_opens.push_back(path); raise } else if let Some(idx) = self.tab_showing(&path) { @@ -617,9 +621,7 @@ impl OpenCADStudio { // on the same widget, so remembering the last text seen pairs // it with the focus check. (An empty field reports its // placeholder — that's iced's "visible text" contract.) - use iced::advanced::widget::operation::{ - Focusable, Outcome, TextInput, - }; + use iced::advanced::widget::operation::{Focusable, Outcome, TextInput}; use iced::advanced::widget::{Id, Operation}; #[derive(Default)] struct FocusedText { @@ -700,9 +702,8 @@ impl OpenCADStudio { if allowed { self.start_mtp_modifier(i); } else { - self.command_line.push_info( - crate::t!("MTP needs an active point prompt.").as_ref(), - ); + self.command_line + .push_info(crate::t!("MTP needs an active point prompt.").as_ref()); } Task::none() } @@ -721,17 +722,14 @@ impl OpenCADStudio { .map(|e| e.to_string_lossy().to_lowercase()) .unwrap_or_default(); if !matches!(ext.as_str(), "dwg" | "dxf" | "bak" | "sv$") { - self.command_line.push_error(crate::tf!( - "Unsupported file type: {}", - path.display() - ).as_ref()); + self.command_line.push_error( + crate::tf!("Unsupported file type: {}", path.display()).as_ref(), + ); return Task::none(); } // A load or recovery report owns the open slot; queue another // drop until that state is acknowledged. - if self.opening.is_some() - || self.active_modal == Some(super::ModalKind::Recovery) - { + if self.opening.is_some() || self.active_modal == Some(super::ModalKind::Recovery) { self.pending_opens.push_back(path); Task::none() } else if let Some(idx) = self.tab_showing(&path) { @@ -780,15 +778,14 @@ impl OpenCADStudio { #[cfg(target_arch = "wasm32")] recovery_bytes: None, #[cfg(not(target_arch = "wasm32"))] - fingerprint: - crate::io::edit_lock::FileFingerprint::capture(&path).ok(), + fingerprint: crate::io::edit_lock::FileFingerprint::capture(&path).ok(), }); let size_label = format_size(size_bytes); self.command_line .push_info(crate::tf!("Opening \"{name}\" ({size_label})…").as_ref()); - let model_bg = self.default_bg_color.unwrap_or_else(|| { - self.model_space.resolve_model_bg(&self.active_theme) - }); + let model_bg = self + .default_bg_color + .unwrap_or_else(|| self.model_space.resolve_model_bg(&self.active_theme)); Task::perform( crate::io::open_path_with_phase(path, progress, model_bg), move |result| Message::FileOpened(open_id, result), @@ -824,9 +821,7 @@ impl OpenCADStudio { crate::io::web_recent::store_open(&name, bytes, open_id).await; (outcome, result) }, - move |(outcome, result)| { - Message::WebFileCached(open_id, outcome, result) - }, + move |(outcome, result)| Message::WebFileCached(open_id, outcome, result), ); } let recent_task = if outcome.record_recent && outcome.result.is_ok() { @@ -846,9 +841,12 @@ impl OpenCADStudio { let recent_task = match cache_result { Ok(()) => self.push_recent(std::path::PathBuf::from(&outcome.name)), Err(error) => { - self.command_line.push_error(crate::tf!( - "Opened drawing, but recent copy could not be stored: {error}" - ).as_ref()); + self.command_line.push_error( + crate::tf!( + "Opened drawing, but recent copy could not be stored: {error}" + ) + .as_ref(), + ); Task::none() } }; @@ -879,7 +877,8 @@ impl OpenCADStudio { // we suppress the noise. let opening = self.opening.take(); if let Some(opening) = opening.filter(|_| e.message != "Cancelled") { - self.command_line.push_error(crate::tf!("Open failed: {e}").as_ref()); + self.command_line + .push_error(crate::tf!("Open failed: {e}").as_ref()); let total_ms = opening.started.elapsed().as_millis() as u32; let failure_phase = crate::io::open_phase_name( opening @@ -912,9 +911,10 @@ impl OpenCADStudio { Message::WebRecentStored(result) => match result { Ok(path) => self.push_recent(path), Err(error) => { - self.command_line.push_error(crate::tf!( - "Saved download, but recent copy could not be stored: {error}" - ).as_ref()); + self.command_line.push_error( + crate::tf!("Saved download, but recent copy could not be stored: {error}") + .as_ref(), + ); Task::none() } }, @@ -943,7 +943,8 @@ impl OpenCADStudio { Message::ImagePickResult(Err(e)) => { if e != "Cancelled" { - self.command_line.push_error(crate::tf!("IMAGE: {e}").as_ref()); + self.command_line + .push_error(crate::tf!("IMAGE: {e}").as_ref()); } Task::none() } @@ -968,9 +969,9 @@ impl OpenCADStudio { ), Message::PdfAttachPickResult(Ok((path, bytes))) => { - use acadrust::objects::{ObjectType, UnderlayDefinition}; use crate::command::CadCommand; use crate::modules::insert::pdf_attach::PdfAttachCommand; + use acadrust::objects::{ObjectType, UnderlayDefinition}; let i = self.active_tab; let path_str = path.to_string_lossy().into_owned(); @@ -981,14 +982,10 @@ impl OpenCADStudio { let mut definition = UnderlayDefinition::pdf(&path_str, "1"); definition.handle = definition_handle; - self.tabs[i] - .scene - .document - .objects - .insert( - definition_handle, - ObjectType::UnderlayDefinition(definition), - ); + self.tabs[i].scene.document.objects.insert( + definition_handle, + ObjectType::UnderlayDefinition(definition), + ); let cmd = PdfAttachCommand::new(definition_handle); @@ -1010,7 +1007,10 @@ impl OpenCADStudio { async { let handle = crate::sys::file_dialog() .set_title(crate::t!("Select External Reference File").as_ref()) - .add_filter(crate::t!("CAD Files").as_ref(), &["dwg", "dxf", "bak", "DWG", "DXF", "BAK"]) + .add_filter( + crate::t!("CAD Files").as_ref(), + &["dwg", "dxf", "bak", "DWG", "DXF", "BAK"], + ) .add_filter(crate::t!("DWG Files").as_ref(), &["dwg", "DWG"]) .add_filter(crate::t!("DXF Files").as_ref(), &["dxf", "DXF"]) .add_filter(crate::t!("Backup Files").as_ref(), &["bak", "BAK"]) @@ -1037,7 +1037,8 @@ impl OpenCADStudio { Message::XAttachPickResult(Err(e)) => { if e != "Cancelled" { - self.command_line.push_error(crate::tf!("XATTACH: {e}").as_ref()); + self.command_line + .push_error(crate::tf!("XATTACH: {e}").as_ref()); } Task::none() } @@ -1067,10 +1068,10 @@ impl OpenCADStudio { Message::WblockWriteFinished(block_name, path, result) => { match result { - Ok(()) => self.command_line.push_output(crate::tf!( - "WBLOCK Saved \"{block_name}\" → \"{}\"", - path.display() - ).as_ref()), + Ok(()) => self.command_line.push_output( + crate::tf!("WBLOCK Saved \"{block_name}\" → \"{}\"", path.display()) + .as_ref(), + ), Err(error) => self .command_line .push_error(crate::tf!("WBLOCK save failed: {error}").as_ref()), @@ -1157,8 +1158,9 @@ impl OpenCADStudio { .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| path.to_string_lossy().into_owned()); - self.command_line - .push_output(crate::tf!("DATAEXTRACTION {rows} rows → \"{fname}\"").as_ref()); + self.command_line.push_output( + crate::tf!("DATAEXTRACTION {rows} rows → \"{fname}\"").as_ref(), + ); } Err(e) => self .command_line @@ -1197,10 +1199,12 @@ impl OpenCADStudio { Message::StlExportFinished(path, result) => { match result { - Ok(()) => self + Ok(()) => self.command_line.push_output( + crate::tf!("STLOUT: exported to \"{}\"", path.display()).as_ref(), + ), + Err(error) => self .command_line - .push_output(crate::tf!("STLOUT: exported to \"{}\"", path.display()).as_ref()), - Err(error) => self.command_line.push_error(crate::tf!("STLOUT: {error}").as_ref()), + .push_error(crate::tf!("STLOUT: {error}").as_ref()), } Task::none() } @@ -1209,8 +1213,9 @@ impl OpenCADStudio { Message::StepExport => { let i = self.active_tab; if self.tabs[i].scene.meshes.is_empty() { - self.command_line - .push_error(crate::t!("STEPOUT: no 3D mesh data in this drawing.").as_ref()); + self.command_line.push_error( + crate::t!("STEPOUT: no 3D mesh data in this drawing.").as_ref(), + ); return Task::none(); } Task::perform( @@ -1234,10 +1239,12 @@ impl OpenCADStudio { Message::StepExportFinished(path, result) => { match result { - Ok(()) => self + Ok(()) => self.command_line.push_output( + crate::tf!("STEPOUT: exported to \"{}\"", path.display()).as_ref(), + ), + Err(error) => self .command_line - .push_output(crate::tf!("STEPOUT: exported to \"{}\"", path.display()).as_ref()), - Err(error) => self.command_line.push_error(crate::tf!("STEPOUT: {error}").as_ref()), + .push_error(crate::tf!("STEPOUT: {error}").as_ref()), } Task::none() } @@ -1262,11 +1269,14 @@ impl OpenCADStudio { Message::ObjImportFinished(tab_id, path, result) => { match result { - Err(error) => self.command_line.push_error(crate::tf!("IMPORTOBJ: {error}").as_ref()), + Err(error) => self + .command_line + .push_error(crate::tf!("IMPORTOBJ: {error}").as_ref()), Ok(mut mesh) => { let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else { - self.command_line - .push_info(crate::t!("IMPORTOBJ: target drawing was closed.").as_ref()); + self.command_line.push_info( + crate::t!("IMPORTOBJ: target drawing was closed.").as_ref(), + ); return Task::none(); }; let file_stem = path @@ -1283,9 +1293,9 @@ impl OpenCADStudio { .meshes .insert(handle, crate::scene::MeshLodSet::from_single(mesh)); self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "IMPORTOBJ: imported \"{file_stem}\" as mesh." - ).as_ref()); + self.command_line.push_output( + crate::tf!("IMPORTOBJ: imported \"{file_stem}\" as mesh.").as_ref(), + ); } } } @@ -1296,8 +1306,9 @@ impl OpenCADStudio { Message::SaveAs => { if self.read_only { - self.command_line - .push_error(crate::t!("Read-only session (--read-only): saving is disabled.").as_ref()); + self.command_line.push_error( + crate::t!("Read-only session (--read-only): saving is disabled.").as_ref(), + ); return Task::none(); } let i = self.active_tab; @@ -1383,15 +1394,16 @@ impl OpenCADStudio { Projection::Perspective }; let i = self.active_tab; - self.tabs[i] - .scene - .set_projection_preserving_frame(proj); + self.tabs[i].scene.set_projection_preserving_frame(proj); self.ribbon.set_ortho(ortho); - self.command_line.push_output(crate::t!(if ortho { - "Projection: Orthographic" - } else { - "Projection: Perspective" - }).as_ref()); + self.command_line.push_output( + crate::t!(if ortho { + "Projection: Orthographic" + } else { + "Projection: Perspective" + }) + .as_ref(), + ); Task::none() } @@ -1512,9 +1524,7 @@ impl OpenCADStudio { &self.tabs[idx].scene.document.header.code_page, ) { crate::scene::text::ttf_glyph::clear_fallback_cache(); - self.tabs[idx] - .scene - .invalidate_text_geometry_dependencies(); + self.tabs[idx].scene.invalidate_text_geometry_dependencies(); } } Task::none() @@ -1526,8 +1536,7 @@ impl OpenCADStudio { } Message::TabReorder { from, to, after } => { - let Some(insertion) = - reorder_insertion_index(from, to, after, self.tabs.len()) + let Some(insertion) = reorder_insertion_index(from, to, after, self.tabs.len()) else { return Task::none(); }; @@ -1584,8 +1593,9 @@ impl OpenCADStudio { { let Some(path) = self.tabs.get(idx).and_then(|tab| tab.current_path.clone()) else { - self.command_line - .push_error(crate::t!("Save the drawing before copying its file path.").as_ref()); + self.command_line.push_error( + crate::t!("Save the drawing before copying its file path.").as_ref(), + ); return Task::none(); }; let full_path = path.canonicalize().unwrap_or_else(|_| { @@ -1605,8 +1615,10 @@ impl OpenCADStudio { #[cfg(target_arch = "wasm32")] { let _ = idx; - self.command_line - .push_error(crate::t!("Full file paths are unavailable in the web application.").as_ref()); + self.command_line.push_error( + crate::t!("Full file paths are unavailable in the web application.") + .as_ref(), + ); Task::none() } } @@ -1616,25 +1628,29 @@ impl OpenCADStudio { { let Some(path) = self.tabs.get(idx).and_then(|tab| tab.current_path.clone()) else { - self.command_line - .push_error(crate::t!("Save the drawing before opening its file location.").as_ref()); + self.command_line.push_error( + crate::t!("Save the drawing before opening its file location.") + .as_ref(), + ); return Task::none(); }; match crate::sys::reveal_in_file_manager(&path) { - Ok(()) => self - .command_line - .push_output(crate::tf!("Opened file location: {}", path.display()).as_ref()), - Err(error) => self - .command_line - .push_error(crate::tf!("Could not open file location: {error}").as_ref()), + Ok(()) => self.command_line.push_output( + crate::tf!("Opened file location: {}", path.display()).as_ref(), + ), + Err(error) => self.command_line.push_error( + crate::tf!("Could not open file location: {error}").as_ref(), + ), } Task::none() } #[cfg(target_arch = "wasm32")] { let _ = idx; - self.command_line - .push_error(crate::t!("File locations are unavailable in the web application.").as_ref()); + self.command_line.push_error( + crate::t!("File locations are unavailable in the web application.") + .as_ref(), + ); Task::none() } } @@ -1651,7 +1667,11 @@ impl OpenCADStudio { // MTEXT bodies), where the typed case is the content and // Space must stay in the buffer. let text_with_spaces = self.is_free_text_active(); - let s = if text_with_spaces { s } else { s.to_uppercase() }; + let s = if text_with_spaces { + s + } else { + s.to_uppercase() + }; // A space submits (acts like Enter). The whole value is handed to // the submit path, which tokenises multi-token lines — so a typed // token, a pasted `LINE 0,0 10,10`, or API-fed text all run their @@ -1692,15 +1712,16 @@ impl OpenCADStudio { let i = self.active_tab; // Capture prompt/options while holding cmd borrow, then release before // borrowing command_line to satisfy borrow checker. - let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { - if cmd.on_live_input(&live_input) { - (true, cmd.options(), cmd.prompt()) + let (should_update, opts, prompt) = + if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if cmd.on_live_input(&live_input) { + (true, cmd.options(), cmd.prompt()) + } else { + (false, Vec::new(), String::new()) + } } else { (false, Vec::new(), String::new()) - } - } else { - (false, Vec::new(), String::new()) - }; + }; if should_update { self.command_line.set_step_options(opts); // Update pinned prompt text live without pushing new history entry @@ -1816,16 +1837,14 @@ impl OpenCADStudio { Message::CommandLineArrowProbe { direction, extend_selection, - } => { - iced::widget::operation::is_focused(iced::widget::Id::new( - crate::ui::command_line::CMD_INPUT_ID, - )) - .map(move |focused| Message::CommandLineArrowResolved { - direction, - focused, - extend_selection, - }) - } + } => iced::widget::operation::is_focused(iced::widget::Id::new( + crate::ui::command_line::CMD_INPUT_ID, + )) + .map(move |focused| Message::CommandLineArrowResolved { + direction, + focused, + extend_selection, + }), Message::CommandLineArrowResolved { direction, @@ -1860,12 +1879,8 @@ impl OpenCADStudio { match direction { ArrowKey::Left => self.mtext_caret_move(-1, extend_selection), ArrowKey::Right => self.mtext_caret_move(1, extend_selection), - ArrowKey::Up => { - self.mtext_caret_move_vertical(1, extend_selection) - } - ArrowKey::Down => { - self.mtext_caret_move_vertical(-1, extend_selection) - } + ArrowKey::Up => self.mtext_caret_move_vertical(1, extend_selection), + ArrowKey::Down => self.mtext_caret_move_vertical(-1, extend_selection), } Task::none() } else { @@ -1908,10 +1923,7 @@ impl OpenCADStudio { let max_height = crate::ui::command_line::history_max_height(self.win_size.1); self.command_line.history_height = (self.command_line.history_height - dy) - .clamp( - crate::ui::command_line::HISTORY_HEIGHT_MIN, - max_height, - ); + .clamp(crate::ui::command_line::HISTORY_HEIGHT_MIN, max_height); } self.command_history_drag_last = Some(point); } @@ -1929,8 +1941,7 @@ impl OpenCADStudio { } Message::CommandHistoryHeightReset => { - self.command_line.history_height = - crate::ui::command_line::HISTORY_HEIGHT_DEFAULT; + self.command_line.history_height = crate::ui::command_line::HISTORY_HEIGHT_DEFAULT; self.save_config(); Task::none() } @@ -1947,10 +1958,16 @@ impl OpenCADStudio { crate::t!("Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history.").as_ref(), crate::t!("Close").as_ref(), ); - Task::perform(async move { - wasm_bindgen_futures::JsFuture::from(promise).await - .ok().and_then(|value| value.as_bool()).unwrap_or(false) - }, Message::CommandHistoryCopied) + Task::perform( + async move { + wasm_bindgen_futures::JsFuture::from(promise) + .await + .ok() + .and_then(|value| value.as_bool()) + .unwrap_or(false) + }, + Message::CommandHistoryCopied, + ) } #[cfg(not(target_arch = "wasm32"))] iced::clipboard::write(text).discard() @@ -2114,8 +2131,9 @@ impl OpenCADStudio { let i = self.active_tab; self.ribbon.close_dropdown(); if self.tabs[i].is_start { - self.command_line - .push_info(crate::t!("Open or create a drawing to manage layer states.").as_ref()); + self.command_line.push_info( + crate::t!("Open or create a drawing to manage layer states.").as_ref(), + ); return Task::none(); } let mut names: Vec = self.tabs[i] @@ -2131,13 +2149,12 @@ impl OpenCADStudio { Task::none() } // ── Layer Translator (#624) ────────────────────────────────── - Message::LayerTranslatorLoad => Task::perform( - crate::io::pick_layer_standard_path(), - |path| match path { + Message::LayerTranslatorLoad => { + Task::perform(crate::io::pick_layer_standard_path(), |path| match path { Some(path) => Message::LayerTranslatorLoaded(path), None => Message::Noop, - }, - ), + }) + } Message::LayerTranslatorLoaded(path) => { use crate::modules::draw::layers::laytrans; match laytrans::load_targets(&path) { @@ -2181,7 +2198,9 @@ impl OpenCADStudio { if let (Some(from), Some(to)) = (state.selected_from.take(), state.selected_to.clone()) { - state.mappings.retain(|m| !m.from.eq_ignore_ascii_case(&from)); + state + .mappings + .retain(|m| !m.from.eq_ignore_ascii_case(&from)); state.mappings.push(Mapping { from, to }); } } @@ -2295,17 +2314,18 @@ impl OpenCADStudio { return Task::none(); } let old_name = self.layer_state_selected.clone(); - let duplicate = self.tabs[i] - .scene - .document - .layer_states() - .into_iter() - .any(|state| { - state.name.eq_ignore_ascii_case(&name) - && old_name - .as_deref() - .is_none_or(|old| !state.name.eq_ignore_ascii_case(old)) - }); + let duplicate = + self.tabs[i] + .scene + .document + .layer_states() + .into_iter() + .any(|state| { + state.name.eq_ignore_ascii_case(&name) + && old_name + .as_deref() + .is_none_or(|old| !state.name.eq_ignore_ascii_case(old)) + }); if duplicate { self.command_line .push_error(crate::tf!("Layer state \"{name}\" already exists.").as_ref()); @@ -2321,15 +2341,16 @@ impl OpenCADStudio { .rename_layer_state(old_name, &name); } } - self.tabs[i].scene.document.capture_layer_state( - &name, - self.layer_state_description_buf.trim(), - ); + self.tabs[i] + .scene + .document + .capture_layer_state(&name, self.layer_state_description_buf.trim()); self.tabs[i].dirty = true; self.layer_state_selected = Some(name.clone()); self.layer_state_name_buf = name.clone(); - self.command_line - .push_output(crate::tf!("LAYERSTATE: saved \"{name}\" in the drawing.").as_ref()); + self.command_line.push_output( + crate::tf!("LAYERSTATE: saved \"{name}\" in the drawing.").as_ref(), + ); Task::none() } Message::LayerStateManagerRestore => { @@ -2362,9 +2383,9 @@ impl OpenCADStudio { .invalidate_layer_dependencies(&layer_names); self.tabs[i].dirty = true; self.refresh_layer_panel(); - self.command_line.push_output(crate::tf!( - "LAYERSTATE: restored \"{name}\" ({restored} layer(s))." - ).as_ref()); + self.command_line.push_output( + crate::tf!("LAYERSTATE: restored \"{name}\" ({restored} layer(s)).").as_ref(), + ); Task::none() } Message::LayerStateManagerDelete => { @@ -2416,9 +2437,7 @@ impl OpenCADStudio { } super::LayerStateProperty::Color => acadrust::LayerStateMask::COLOR, super::LayerStateProperty::LineType => acadrust::LayerStateMask::LINE_TYPE, - super::LayerStateProperty::LineWeight => { - acadrust::LayerStateMask::LINE_WEIGHT - } + super::LayerStateProperty::LineWeight => acadrust::LayerStateMask::LINE_WEIGHT, super::LayerStateProperty::PlotStyle => acadrust::LayerStateMask::PLOT_STYLE, super::LayerStateProperty::Transparency => { acadrust::LayerStateMask::TRANSPARENCY @@ -2450,12 +2469,12 @@ impl OpenCADStudio { Task::none() } Message::LayerStateEditorLayerColorToggle(index) => { - self.layer_state_edit_color_open = if self.layer_state_edit_color_open == Some(index) - { - None - } else { - Some(index) - }; + self.layer_state_edit_color_open = + if self.layer_state_edit_color_open == Some(index) { + None + } else { + Some(index) + }; Task::none() } Message::LayerStateEditorLayerColor(index, color) => { @@ -2543,17 +2562,18 @@ impl OpenCADStudio { return Task::none(); } let old_name = self.layer_state_selected.clone(); - let duplicate = self.tabs[i] - .scene - .document - .layer_states() - .into_iter() - .any(|state| { - state.name.eq_ignore_ascii_case(&name) - && old_name - .as_deref() - .is_none_or(|old| !state.name.eq_ignore_ascii_case(old)) - }); + let duplicate = + self.tabs[i] + .scene + .document + .layer_states() + .into_iter() + .any(|state| { + state.name.eq_ignore_ascii_case(&name) + && old_name + .as_deref() + .is_none_or(|old| !state.name.eq_ignore_ascii_case(old)) + }); if duplicate { self.command_line .push_error(crate::tf!("Layer state \"{name}\" already exists.").as_ref()); @@ -2629,8 +2649,11 @@ impl OpenCADStudio { if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) { dl.flags.off = !on; } - if let Some(pl) = - self.tabs[i].layers.layers.iter_mut().find(|l| &l.name == name) + if let Some(pl) = self.tabs[i] + .layers + .layers + .iter_mut() + .find(|l| &l.name == name) { pl.visible = on; } @@ -2638,11 +2661,14 @@ impl OpenCADStudio { self.tabs[i].scene.invalidate_layer_dependencies(&targets); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); - self.command_line.push_output(crate::tf!( - "{} layer(s) turned {}", - targets.len(), - if on { "on" } else { "off" } - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "{} layer(s) turned {}", + targets.len(), + if on { "on" } else { "off" } + ) + .as_ref(), + ); self.sync_ribbon_layers(); } } @@ -2669,8 +2695,11 @@ impl OpenCADStudio { if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) { dl.flags.locked = locked; } - if let Some(pl) = - self.tabs[i].layers.layers.iter_mut().find(|l| &l.name == name) + if let Some(pl) = self.tabs[i] + .layers + .layers + .iter_mut() + .find(|l| &l.name == name) { pl.locked = locked; } @@ -2678,11 +2707,14 @@ impl OpenCADStudio { // Lock state affects editability, not rendered geometry. self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); - self.command_line.push_output(crate::tf!( - "{} layer(s) {}", - targets.len(), - if locked { "locked" } else { "unlocked" } - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "{} layer(s) {}", + targets.len(), + if locked { "locked" } else { "unlocked" } + ) + .as_ref(), + ); self.sync_ribbon_layers(); self.refresh_properties(); } @@ -2705,8 +2737,11 @@ impl OpenCADStudio { dl.thaw(); } } - if let Some(pl) = - self.tabs[i].layers.layers.iter_mut().find(|l| &l.name == name) + if let Some(pl) = self.tabs[i] + .layers + .layers + .iter_mut() + .find(|l| &l.name == name) { pl.frozen = frozen; } @@ -2714,11 +2749,14 @@ impl OpenCADStudio { self.tabs[i].scene.invalidate_layer_dependencies(&targets); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); - self.command_line.push_output(crate::tf!( - "{} layer(s) {}", - targets.len(), - if frozen { "frozen" } else { "thawed" } - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "{} layer(s) {}", + targets.len(), + if frozen { "frozen" } else { "thawed" } + ) + .as_ref(), + ); self.sync_ribbon_layers(); } } @@ -2756,9 +2794,7 @@ impl OpenCADStudio { self.tabs[i].layers.refresh_sort(); - self.tabs[i] - .scene - .invalidate_layer_dependencies(&targets); + self.tabs[i].scene.invalidate_layer_dependencies(&targets); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); @@ -2775,7 +2811,7 @@ impl OpenCADStudio { } Task::none() - }, + } Message::LayerToggleVpFreeze(layer_idx, vp_col_idx) => { self.on_layer_toggle_vp_freeze(layer_idx, vp_col_idx) @@ -2956,14 +2992,15 @@ impl OpenCADStudio { if let Some(v) = val { let targets = self.layer_row_action_targets(i, idx); for name in &targets { - if let Some(layer) = - self.tabs[i].scene.document.layers.get_mut(name) - { + if let Some(layer) = self.tabs[i].scene.document.layers.get_mut(name) { layer.transparency = acadrust::types::Transparency::from_percent(v as f64 / 100.0); } - if let Some(pl) = - self.tabs[i].layers.layers.iter_mut().find(|l| &l.name == name) + if let Some(pl) = self.tabs[i] + .layers + .layers + .iter_mut() + .find(|l| &l.name == name) { pl.transparency = v; } @@ -3176,7 +3213,8 @@ impl OpenCADStudio { self.tabs[i].scene.camera.borrow_mut().home_view(r_ucs); } self.tabs[i].scene.camera_generation += 1; - self.command_line.push_output(crate::t!("View: Home").as_ref()); + self.command_line + .push_output(crate::t!("View: Home").as_ref()); Task::none() } @@ -3231,11 +3269,13 @@ impl OpenCADStudio { let mut changed = false; if name.is_empty() || name == "WCS" { self.tabs[i].active_ucs = None; - self.command_line.push_output(crate::t!("UCS: World").as_ref()); + self.command_line + .push_output(crate::t!("UCS: World").as_ref()); changed = true; } else if let Some(named) = self.tabs[i].scene.document.ucss.get(&name).cloned() { self.tabs[i].active_ucs = Some(named); - self.command_line.push_output(crate::tf!("UCS: {}", name).as_ref()); + self.command_line + .push_output(crate::tf!("UCS: {}", name).as_ref()); changed = true; } if changed { @@ -3283,9 +3323,9 @@ impl OpenCADStudio { .iter() .position(|tab| tab.id == tab_id) .is_some_and(|i| { - self.tabs[i].scene.install_prepared_interaction_index( - epoch, source, wires, index, - ) + self.tabs[i] + .scene + .install_prepared_interaction_index(epoch, source, wires, index) }); if crate::perf::enabled() { crate::perf_record!( @@ -3314,11 +3354,8 @@ impl OpenCADStudio { } else { (queued_wires, screen_height) }; - if let Some(task) = self.prepare_interaction_index_task( - i, - wires, - screen_height, - ) { + if let Some(task) = self.prepare_interaction_index_task(i, wires, screen_height) + { return task; } } @@ -3371,10 +3408,9 @@ impl OpenCADStudio { } else { self.isometric_drafting = true; } - self.command_line.push_output(crate::tf!( - "Isometric plane: {}.", - self.iso_plane.label() - ).as_ref()); + self.command_line.push_output( + crate::tf!("Isometric plane: {}.", self.iso_plane.label()).as_ref(), + ); self.persist_settings_if_changed(); Task::none() } @@ -3437,8 +3473,8 @@ impl OpenCADStudio { } Task::none() } - Message::ResolveOneSketchConflict => { - self.resolve_one_sketch_conflict(); + Message::ResolveOneParametricConflict => { + self.resolve_one_parametric_conflict(); Task::none() } Message::TogglePolar => { @@ -3627,12 +3663,15 @@ impl OpenCADStudio { self.anno_object_scale_target = Some(handles[0]); self.active_modal = Some(crate::app::ModalKind::AnnoObjectScale); } else { - self.command_line - .push_info(crate::t!("The selected object does not support annotation scales.").as_ref()); + self.command_line.push_info( + crate::t!("The selected object does not support annotation scales.") + .as_ref(), + ); } } else { - self.command_line - .push_info(crate::t!("Select one object first, then run OBJECTSCALE.").as_ref()); + self.command_line.push_info( + crate::t!("Select one object first, then run OBJECTSCALE.").as_ref(), + ); } Task::none() } @@ -3645,9 +3684,10 @@ impl OpenCADStudio { if let Some(sh) = self.tabs[i].scene.scale_handle_ensuring(&name) { self.push_undo_snapshot(i, "OBJECTSCALE"); let doc = &mut self.tabs[i].scene.document; - let is_member = crate::scene::annotative::object_scale_memberships(doc, entity) - .iter() - .any(|(_, h)| *h == sh); + let is_member = + crate::scene::annotative::object_scale_memberships(doc, entity) + .iter() + .any(|(_, h)| *h == sh); if is_member { crate::scene::annotative::remove_annotation_context_for_scale( doc, entity, sh, @@ -3697,8 +3737,10 @@ impl OpenCADStudio { let i = self.active_tab; let sel = self.scale_manager_selected.clone(); if !sel.is_empty() { - let (paper, drawing) = - self.tabs[i].scene.scale_paper_drawing(&sel).unwrap_or((1.0, 1.0)); + let (paper, drawing) = self.tabs[i] + .scene + .scale_paper_drawing(&sel) + .unwrap_or((1.0, 1.0)); let name = self.unique_scale_name(&sel); if self.tabs[i].scene.add_scale(&name, paper, drawing) { self.load_scale_editor(&name); @@ -3723,8 +3765,10 @@ impl OpenCADStudio { if let Some(old) = self.scale_rename.take() { let new = self.scale_rename_buf.trim().to_string(); if !new.is_empty() && !new.eq_ignore_ascii_case(&old) { - let (paper, drawing) = - self.tabs[i].scene.scale_paper_drawing(&old).unwrap_or((1.0, 1.0)); + let (paper, drawing) = self.tabs[i] + .scene + .scale_paper_drawing(&old) + .unwrap_or((1.0, 1.0)); // Only fall back to add_scale for a built-in fallback (no // real object); never for a real scale whose rename was // rejected (name collision) — that would duplicate it. @@ -3839,8 +3883,7 @@ impl OpenCADStudio { Message::ToggleQuickProperties => { self.quick_properties ^= true; if self.quick_properties { - self.quick_properties_anchor = - self.tabs[self.active_tab].last_cursor_screen; + self.quick_properties_anchor = self.tabs[self.active_tab].last_cursor_screen; } self.save_config(); Task::none() @@ -3853,10 +3896,8 @@ impl OpenCADStudio { } Message::CycleSelect(handle) => { // Add the picked object to the current selection (accumulate). - let quick_properties_anchor = self - .cycle_candidates - .as_ref() - .map(|(point, _)| *point); + let quick_properties_anchor = + self.cycle_candidates.as_ref().map(|(point, _)| *point); self.cycle_candidates = None; let i = self.active_tab; self.tabs[i].scene.set_hover_highlight(None); @@ -4082,7 +4123,8 @@ impl OpenCADStudio { self.close_active_modal(); self.snap_popup_open = false; } else { - let st = crate::ui::window::drafting_settings::DraftingSettingsState::from_app(self); + let st = + crate::ui::window::drafting_settings::DraftingSettingsState::from_app(self); self.drafting_settings_saved = Some(st.clone()); self.drafting_settings_state = Some(st); self.drafting_settings_close_confirm = false; @@ -4622,9 +4664,8 @@ impl OpenCADStudio { self.focus_cmd_input() } Text::EmptyOrUnsupported => { - self.command_line.push_error( - crate::tr!("clipboard", "no-supported-content").as_ref(), - ); + self.command_line + .push_error(crate::tr!("clipboard", "no-supported-content").as_ref()); Task::none() } Text::Unavailable => { @@ -4742,8 +4783,9 @@ impl OpenCADStudio { let i = self.active_tab; self.tabs[i].scene.selection.borrow_mut().context_menu = None; let count = self.tabs[i].scene.invert_selection(); - self.command_line - .push_output(crate::tf!("Invert Selection: {} object(s) selected.", count).as_ref()); + self.command_line.push_output( + crate::tf!("Invert Selection: {} object(s) selected.", count).as_ref(), + ); self.refresh_properties(); Task::none() } @@ -4790,11 +4832,12 @@ impl OpenCADStudio { if matches!(scope, crate::app::QSelectScope::CurrentSelection) { state.append = false; } - if matches!(state.operator, crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt) - && !state.property.as_ref().is_some_and(|property| { - matches!(property.editor, crate::app::QSelectValueEditor::Number) - }) - { + if matches!( + state.operator, + crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt + ) && !state.property.as_ref().is_some_and(|property| { + matches!(property.editor, crate::app::QSelectValueEditor::Number) + }) { state.operator = crate::app::QSelectOp::Eq; } } @@ -4807,9 +4850,7 @@ impl OpenCADStudio { .qselect .as_ref() .map_or(crate::app::QSelectScope::CurrentSpace, |state| state.scope); - let properties = self.tabs[i] - .scene - .qselect_properties(t.as_deref(), scope); + let properties = self.tabs[i].scene.qselect_properties(t.as_deref(), scope); if let Some(state) = self.qselect.as_mut() { let kept_property = state.property.as_ref().and_then(|selected| { properties @@ -4822,11 +4863,12 @@ impl OpenCADStudio { state.property = kept_property; state.value.clear(); state.error = None; - if matches!(state.operator, crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt) - && !state.property.as_ref().is_some_and(|property| { - matches!(property.editor, crate::app::QSelectValueEditor::Number) - }) - { + if matches!( + state.operator, + crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt + ) && !state.property.as_ref().is_some_and(|property| { + matches!(property.editor, crate::app::QSelectValueEditor::Number) + }) { state.operator = crate::app::QSelectOp::Eq; } } @@ -4838,11 +4880,12 @@ impl OpenCADStudio { state.property = p; state.value.clear(); state.error = None; - if matches!(state.operator, crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt) - && !state.property.as_ref().is_some_and(|property| { - matches!(property.editor, crate::app::QSelectValueEditor::Number) - }) - { + if matches!( + state.operator, + crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt + ) && !state.property.as_ref().is_some_and(|property| { + matches!(property.editor, crate::app::QSelectValueEditor::Number) + }) { state.operator = crate::app::QSelectOp::Eq; } } @@ -4907,7 +4950,8 @@ impl OpenCADStudio { } else { match &property.editor { crate::app::QSelectValueEditor::Number - if crate::entities::common::parse_f64(&state.value).is_none() => + if crate::entities::common::parse_f64(&state.value) + .is_none() => { Some(crate::t!("Enter a valid number.").into_owned()) } @@ -4944,8 +4988,7 @@ impl OpenCADStudio { state.operator, &state.value, state.mode, - state.append - && matches!(state.scope, crate::app::QSelectScope::CurrentSpace), + state.append && matches!(state.scope, crate::app::QSelectScope::CurrentSpace), ); self.command_line .push_output(crate::tf!("QSELECT: {} object(s) selected.", matched).as_ref()); @@ -4985,9 +5028,7 @@ impl OpenCADStudio { // re-tessellate so the change shows immediately (issue #231 // class). self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = - app.tabs[i].scene.document.get_entity_mut(handle) - { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_line_weight(entity, lw); } }); @@ -5007,12 +5048,8 @@ impl OpenCADStudio { return task; } self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = - app.tabs[i].scene.document.get_entity_mut(handle) - { - crate::scene::view::dispatch::apply_common_prop( - entity, "layer", &layer, - ); + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_common_prop(entity, "layer", &layer); } }); Task::none() @@ -5027,9 +5064,7 @@ impl OpenCADStudio { return task; } self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = - app.tabs[i].scene.document.get_entity_mut(handle) - { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_color(entity, color); } }); @@ -5051,9 +5086,7 @@ impl OpenCADStudio { return Task::none(); } self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = - app.tabs[i].scene.document.get_entity_mut(handle) - { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_line_weight(entity, lw); } }); @@ -5095,12 +5128,8 @@ impl OpenCADStudio { return task; } self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = - app.tabs[i].scene.document.get_entity_mut(handle) - { - crate::scene::view::dispatch::apply_common_prop( - entity, "linetype", <, - ); + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_common_prop(entity, "linetype", <); } }); Task::none() @@ -5120,11 +5149,10 @@ impl OpenCADStudio { panel.color_picker_open = false; panel.open_color_field = None; panel.edit_choice_open = false; - panel.hatch_pattern_focus = - crate::ui::properties::filtered_hatch_patterns("") - .iter() - .position(|entry| entry.name.eq_ignore_ascii_case(¤t)) - .unwrap_or(0); + panel.hatch_pattern_focus = crate::ui::properties::filtered_hatch_patterns("") + .iter() + .position(|entry| entry.name.eq_ignore_ascii_case(¤t)) + .unwrap_or(0); return iced::widget::operation::focus(iced::widget::Id::new( "hatch-pattern-search", )); @@ -5244,8 +5272,7 @@ impl OpenCADStudio { }; let Some((sx, sy, sz)) = scales else { return }; let eq = (sx - sy).abs() < 1e-12 && (sx - sz).abs() < 1e-12; - let checked = - eq && !app.props_asym_scale.contains(&handle.value()); + let checked = eq && !app.props_asym_scale.contains(&handle.value()); if checked { app.props_asym_scale.insert(handle.value()); } else { @@ -5266,16 +5293,17 @@ impl OpenCADStudio { else { return; }; - let table_style = table.table_style_handle.and_then(|style_handle| { - document.objects.get(&style_handle).and_then(|object| { - match object { - acadrust::objects::ObjectType::TableStyle(style) => { - Some(style) + let table_style = + table.table_style_handle.and_then(|style_handle| { + document.objects.get(&style_handle).and_then(|object| { + match object { + acadrust::objects::ObjectType::TableStyle( + style, + ) => Some(style), + _ => None, } - _ => None, - } - }) - }); + }) + }); let current = if field == "tbl_title_suppressed" { crate::entities::table::resolved_title_suppressed( table, @@ -5331,7 +5359,11 @@ impl OpenCADStudio { Some(acadrust::EntityType::Polyline2D(p)) => p.vertices.len(), Some(acadrust::EntityType::PolygonMesh(p)) => p.vertices.len(), Some(acadrust::EntityType::Face3D(face)) => { - if face.is_triangle() { 3 } else { 4 } + if face.is_triangle() { + 3 + } else { + 4 + } } Some(acadrust::EntityType::Polyline3D(p)) => { crate::entities::polyline::polyline3d_control_vertex_count(p) @@ -5472,8 +5504,7 @@ impl OpenCADStudio { m.background_color = color.clone(); // Picking a colour turns the background on in Fill // mode (specific colour), preserving the frame bit. - m.background_fill_flags = - (m.background_fill_flags & !0x02) | 0x01; + m.background_fill_flags = (m.background_fill_flags & !0x02) | 0x01; } Some(acadrust::EntityType::Hatch(h)) => { crate::entities::hatch::set_background_color(h, &color); @@ -5579,8 +5610,7 @@ impl OpenCADStudio { } if matches!( field.as_str(), - "line_color" | "text_color" | "block_content_color" - | "background_fill_color" + "line_color" | "text_color" | "block_content_color" | "background_fill_color" ) { self.apply_property_op(i, "CHPROP", &handles, |app, handle| { if let Some(acadrust::EntityType::MultiLeader(leader)) = @@ -5807,8 +5837,9 @@ impl OpenCADStudio { Message::LayoutManagerOpen => { let i = self.active_tab; if self.tabs[i].is_start { - self.command_line - .push_info(crate::t!("Open or create a drawing to manage layouts.").as_ref()); + self.command_line.push_info( + crate::t!("Open or create a drawing to manage layouts.").as_ref(), + ); return Task::none(); } let current = self.tabs[i].scene.current_layout.clone(); @@ -5846,7 +5877,8 @@ impl OpenCADStudio { self.command_line .push_error(crate::t!("Cannot rename the Model layout.").as_ref()); } else if new_name.is_empty() { - self.command_line.push_error(crate::t!("Layout name cannot be empty.").as_ref()); + self.command_line + .push_error(crate::t!("Layout name cannot be empty.").as_ref()); } else if new_name == old_name { // no-op } else { @@ -5857,8 +5889,9 @@ impl OpenCADStudio { } self.layout_manager_selected = new_name.clone(); self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("Layout renamed: '{old_name}' → '{new_name}'").as_ref()); + self.command_line.push_output( + crate::tf!("Layout renamed: '{old_name}' → '{new_name}'").as_ref(), + ); } Task::none() } @@ -5883,7 +5916,9 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("Layout '{name}' created.").as_ref()); } - Err(e) => self.command_line.push_error(crate::tf!("LAYOUT: {e}").as_ref()), + Err(e) => self + .command_line + .push_error(crate::tf!("LAYOUT: {e}").as_ref()), } Task::none() } @@ -5966,8 +6001,7 @@ impl OpenCADStudio { self.saved_custom_palette = Some(self.ui_theme.palette); } self.ui_theme.name = theme.to_string(); - self.ui_theme.palette = - crate::app::config::UiThemePalette::from_iced(theme.seed()); + self.ui_theme.palette = crate::app::config::UiThemePalette::from_iced(theme.seed()); self.theme_color_inputs = self.ui_theme.palette.hex_values(); self.active_theme = theme; self.sync_model_space_theme(true); @@ -6153,10 +6187,9 @@ impl OpenCADStudio { } Message::AliasEditorApply => { self.apply_alias_editor_rows(); - self.command_line.push_info(crate::tf!( - "{} alias(es) applied.", - self.command_aliases.len() - ).as_ref()); + self.command_line.push_info( + crate::tf!("{} alias(es) applied.", self.command_aliases.len()).as_ref(), + ); Task::none() } @@ -6167,7 +6200,10 @@ impl OpenCADStudio { .scene .named_parameters() .iter() - .map(|p| crate::ui::window::named_parameters::ParamEditorRow { name: p.name.clone(), formula: p.source.clone() }) + .map(|p| crate::ui::window::named_parameters::ParamEditorRow { + name: p.name.clone(), + formula: p.source.clone(), + }) .collect(); self.active_modal = Some(super::ModalKind::NamedParameters); Task::none() @@ -6183,7 +6219,8 @@ impl OpenCADStudio { Task::none() } Message::NamedParametersAdd => { - self.named_parameter_editor_rows.push(crate::ui::window::named_parameters::ParamEditorRow::default()); + self.named_parameter_editor_rows + .push(crate::ui::window::named_parameters::ParamEditorRow::default()); Task::none() } Message::NamedParametersRemove(idx) => { @@ -6199,7 +6236,11 @@ impl OpenCADStudio { // ── Parameters / Constraints sections embedded in the // Properties panel ────────────────────────────────────────────── - Message::PropParamInput { index, field, value } => { + Message::PropParamInput { + index, + field, + value, + } => { self.tabs[self.active_tab] .properties .edit_buf @@ -6283,8 +6324,7 @@ impl OpenCADStudio { } Message::DefaultSaveFormatChanged(format) => { - self.default_save_format = - crate::io::canonical_save_format(&format).to_string(); + self.default_save_format = crate::io::canonical_save_format(&format).to_string(); self.persist_settings_if_changed(); Task::none() } @@ -6294,9 +6334,7 @@ impl OpenCADStudio { self.saved_custom_palette = Some(self.ui_theme.palette); } self.ui_theme.name = name; - if let Some(theme) = - crate::app::config::builtin_theme(&self.ui_theme.name) - { + if let Some(theme) = crate::app::config::builtin_theme(&self.ui_theme.name) { self.ui_theme.palette = crate::app::config::UiThemePalette::from_iced(theme.seed()); self.theme_color_inputs = self.ui_theme.palette.hex_values(); @@ -6521,7 +6559,8 @@ impl OpenCADStudio { Message::ClipromptLinesChanged(lines) => { let lines = crate::app::settings::clamp_clipromptlines(lines); self.cliprompt_lines = lines; - self.command_line.set_cliprompt_lines(lines.clamp(0, 50) as u8); + self.command_line + .set_cliprompt_lines(lines.clamp(0, 50) as u8); self.persist_settings_if_changed(); Task::none() } @@ -6663,9 +6702,7 @@ impl OpenCADStudio { } Message::ShowHistChanged(mode) => { - self.set_drawing_var(|header| { - header.show_solid_history = mode.clamp(0, 2) - }); + self.set_drawing_var(|header| header.show_solid_history = mode.clamp(0, 2)); let i = self.active_tab; if let Some(tab) = self.tabs.get_mut(i) { tab.scene.bump_geometry(); @@ -6741,11 +6778,6 @@ impl OpenCADStudio { } Task::none() } - Message::WriteDwgNativeConstraintsChanged(enabled) => { - self.write_dwg_native_constraints = enabled; - self.persist_settings_if_changed(); - Task::none() - } Message::ShowConstraintValuesChanged(enabled) => { self.show_constraint_values = enabled; self.persist_settings_if_changed(); @@ -6833,9 +6865,9 @@ impl OpenCADStudio { self.close_active_modal(); return Task::none(); }; - let model_bg = self.default_bg_color.unwrap_or_else(|| { - self.model_space.resolve_model_bg(&self.active_theme) - }); + let model_bg = self + .default_bg_color + .unwrap_or_else(|| self.model_space.resolve_model_bg(&self.active_theme)); #[cfg(not(target_arch = "wasm32"))] if let Some(path) = opening.source_path.clone() { let current_fingerprint = @@ -6914,10 +6946,8 @@ impl OpenCADStudio { let declined = self.opening.take(); self.close_active_modal(); if let Some(opening) = declined { - self.command_line.push_info(crate::tf!( - "Recovery cancelled: \"{}\"", - opening.name - ).as_ref()); + self.command_line + .push_info(crate::tf!("Recovery cancelled: \"{}\"", opening.name).as_ref()); } self.drain_pending_open() } @@ -6950,9 +6980,9 @@ impl OpenCADStudio { { if let Some(path) = &report.log_path { if let Err(error) = crate::sys::reveal_in_file_manager(path) { - self.command_line.push_error(crate::tf!( - "Could not show recovery log: {error}" - ).as_ref()); + self.command_line.push_error( + crate::tf!("Could not show recovery log: {error}").as_ref(), + ); } } } @@ -7089,13 +7119,11 @@ impl OpenCADStudio { if first_measurement { let initial_width = self.mtext_editor.as_ref().and_then(|editor| { editor.editing.is_none().then(|| { - (size.width - 2.0 * super::view::overlay::MTEXT_PREVIEW_PAD) - .max(80.0) + (size.width - 2.0 * super::view::overlay::MTEXT_PREVIEW_PAD).max(80.0) / editor.preview_scale() }) }); - if let (Some(editor), Some(width)) = - (self.mtext_editor.as_mut(), initial_width) + if let (Some(editor), Some(width)) = (self.mtext_editor.as_mut(), initial_width) { editor.rect_width = f64::from(width.max(1e-6)); self.rebuild_mtext_preview(); @@ -7231,7 +7259,9 @@ impl OpenCADStudio { .external_plugins .iter() .find_map(|plugin| plugin.repository.clone()) - .or_else(|| self.plugin_registry.first().map(|entry| entry.repo.clone())) + .or_else(|| { + self.plugin_registry.first().map(|entry| entry.repo.clone()) + }) .or_else(|| self.plugin_repos.first().cloned()); } if let Some(repo) = self.selected_plugin_repo.clone() { @@ -7271,13 +7301,15 @@ impl OpenCADStudio { crate::plugin::external::normalize_repository(&self.plugin_repo_input) else { self.marketplace_status = - crate::t!("Enter a GitHub URL or repository in owner/repo format.").into_owned(); + crate::t!("Enter a GitHub URL or repository in owner/repo format.") + .into_owned(); return Task::none(); }; if self.plugin_repos.contains(&repo) || self.plugin_registry.iter().any(|entry| entry.repo == repo) { - self.marketplace_status = crate::tf!("{repo} is already in the catalog.").into_owned(); + self.marketplace_status = + crate::tf!("{repo} is already in the catalog.").into_owned(); self.selected_plugin_repo = Some(repo.clone()); if !self.plugin_readmes.contains_key(&repo) && self.plugin_readme_loading.insert(repo.clone()) @@ -7291,7 +7323,8 @@ impl OpenCADStudio { .iter() .any(|plugin| plugin.repository.as_deref() == Some(repo.as_str())) { - self.marketplace_status = crate::tf!("{repo} is already installed.").into_owned(); + self.marketplace_status = + crate::tf!("{repo} is already installed.").into_owned(); self.selected_plugin_repo = Some(repo.clone()); if !self.plugin_readmes.contains_key(&repo) && self.plugin_readme_loading.insert(repo.clone()) @@ -7449,12 +7482,14 @@ impl OpenCADStudio { .entry(repo.clone()) .or_insert_with(|| first.tag.clone()); } - if self.marketplace_status == crate::tf!("Fetching releases for {repo}…").into_owned() { - self.marketplace_status = - crate::tf!( - "Repository added. {} installable release(s) found.", - releases.len() - ).into_owned(); + if self.marketplace_status + == crate::tf!("Fetching releases for {repo}…").into_owned() + { + self.marketplace_status = crate::tf!( + "Repository added. {} installable release(s) found.", + releases.len() + ) + .into_owned(); } self.repo_release_tags.insert(repo, releases); Task::none() @@ -7500,7 +7535,8 @@ impl OpenCADStudio { self.install_task(repo, tag) } Message::PluginInstalled(Ok(id)) => { - self.marketplace_status = crate::tf!("Installed '{id}'. Restart to load it.").into_owned(); + self.marketplace_status = + crate::tf!("Installed '{id}'. Restart to load it.").into_owned(); self.plugin_load_errors.remove(&id); #[cfg(not(target_arch = "wasm32"))] { @@ -7519,7 +7555,8 @@ impl OpenCADStudio { // and allows the package directory to be deleted. if !crate::plugin::external::remove_plugin(&id) { self.marketplace_status = - crate::tf!("Uninstall failed: plugin '{id}' did not stop in time").into_owned(); + crate::tf!("Uninstall failed: plugin '{id}' did not stop in time") + .into_owned(); return Task::none(); } match crate::plugin::external::uninstall(&id) { @@ -7532,7 +7569,8 @@ impl OpenCADStudio { self.external_plugins = crate::plugin::external::discover(); } Err(e) => { - self.marketplace_status = crate::tf!("Uninstall failed: {e}").into_owned(); + self.marketplace_status = + crate::tf!("Uninstall failed: {e}").into_owned(); } } } @@ -7644,8 +7682,9 @@ impl OpenCADStudio { Message::MspaceCommand => { let i = self.active_tab; if self.tabs[i].scene.current_layout == "Model" { - self.command_line - .push_error(crate::t!("MS is only available in paper space layouts.").as_ref()); + self.command_line.push_error( + crate::t!("MS is only available in paper space layouts.").as_ref(), + ); return Task::none(); } if self.tabs[i].scene.active_viewport.is_some() { @@ -7743,14 +7782,7 @@ impl OpenCADStudio { version, bounds, screenshot, - } => self.on_web_save_screenshot( - tab_id, - filename, - ext, - version, - bounds, - screenshot, - ), + } => self.on_web_save_screenshot(tab_id, filename, ext, version, bounds, screenshot), #[cfg(not(target_arch = "wasm32"))] Message::SaveFileInUseRetry => self.on_save_file_in_use_retry(), @@ -7800,10 +7832,8 @@ impl OpenCADStudio { Task::none() } Message::UpdateNoticeOpenRelease => { - let open = crate::sys::open_url( - crate::io::update_check::RELEASES_PAGE, - self.main_window, - ); + let open = + crate::sys::open_url(crate::io::update_check::RELEASES_PAGE, self.main_window); self.close_active_modal(); open } @@ -7991,7 +8021,8 @@ impl OpenCADStudio { Task::none() } Message::PrintResult(Err(e)) => { - self.command_line.push_error(crate::tf!("Print failed: {e}").as_ref()); + self.command_line + .push_error(crate::tf!("Print failed: {e}").as_ref()); Task::none() } @@ -8002,21 +8033,27 @@ impl OpenCADStudio { Message::PlotStyleLoaded(Some(table)) => { if table.is_stb { self.command_line.push_error( - crate::t!("Named plot style tables are not supported by the vector plotter.").as_ref(), + crate::t!( + "Named plot style tables are not supported by the vector plotter." + ) + .as_ref(), ); return Task::none(); } self.plot_dialog.style_name = table.name.clone(); self.plot_dialog.style_missing = false; - self.command_line.push_output(crate::tf!( - "Plot style '{}' loaded ({} color entries).", - table.name, - table - .aci_entries - .iter() - .filter(|e| e.color.is_some()) - .count() - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "Plot style '{}' loaded ({} color entries).", + table.name, + table + .aci_entries + .iter() + .filter(|e| e.color.is_some()) + .count() + ) + .as_ref(), + ); self.active_plot_style = Some(table); self.plot_dialog.plot_styles = crate::io::plot_style::available_ctb_names(); Task::none() @@ -8026,7 +8063,8 @@ impl OpenCADStudio { self.active_plot_style = None; self.plot_dialog.style_name.clear(); self.plot_dialog.style_missing = false; - self.command_line.push_output(crate::t!("Plot style table cleared.").as_ref()); + self.command_line + .push_output(crate::t!("Plot style table cleared.").as_ref()); Task::none() } @@ -8137,62 +8175,59 @@ impl OpenCADStudio { Message::PlotStylePanelApply => self.on_plot_style_panel_apply(), - Message::PlotStylePanelSaveDirect => { - if self.active_plot_style.is_none() { - self.command_line.push_error( - crate::t!("No plot style table loaded. Load or create one first.").as_ref(), - ); - return Task::none(); - } - - #[cfg(not(target_arch = "wasm32"))] - { - let table = self.active_plot_style.as_ref().expect("checked above"); - let table_name = table.name.clone(); - - let result = crate::io::plot_style::ensure_plot_styles_dir().and_then(|dir| { - let path = dir.join(&table_name); - table.save(&path)?; - Ok(path) - }); - - match result { - Ok(path) => { - self.plot_dialog.style_name = table_name; - self.plot_dialog.style_missing = false; - self.plot_dialog.plot_styles = - crate::io::plot_style::available_ctb_names(); - self.tabs[self.active_tab] - .scene - .invalidate_display_plot_style(); - - self.command_line.push_output( - crate::tf!( - "Plot style table saved to \"{}\".", - path.display() - ) - .as_ref(), - ); - } - Err(error) => { - self.command_line - .push_error(crate::tf!("Save error: {error}").as_ref()); - } + Message::PlotStylePanelSaveDirect => { + if self.active_plot_style.is_none() { + self.command_line.push_error( + crate::t!("No plot style table loaded. Load or create one first.").as_ref(), + ); + return Task::none(); } - Task::none() + #[cfg(not(target_arch = "wasm32"))] + { + let table = self.active_plot_style.as_ref().expect("checked above"); + let table_name = table.name.clone(); + + let result = crate::io::plot_style::ensure_plot_styles_dir().and_then(|dir| { + let path = dir.join(&table_name); + table.save(&path)?; + Ok(path) + }); + + match result { + Ok(path) => { + self.plot_dialog.style_name = table_name; + self.plot_dialog.style_missing = false; + self.plot_dialog.plot_styles = + crate::io::plot_style::available_ctb_names(); + self.tabs[self.active_tab] + .scene + .invalidate_display_plot_style(); + + self.command_line.push_output( + crate::tf!("Plot style table saved to \"{}\".", path.display()) + .as_ref(), + ); + } + Err(error) => { + self.command_line + .push_error(crate::tf!("Save error: {error}").as_ref()); + } + } + + Task::none() + } + + #[cfg(target_arch = "wasm32")] + { + // Browsers cannot overwrite a local file directly, so fall back + // to the existing Save As flow. + self.on_plot_style_panel_save() + } } - #[cfg(target_arch = "wasm32")] - { - // Browsers cannot overwrite a local file directly, so fall back - // to the existing Save As flow. - self.on_plot_style_panel_save() - } - } - - Message::PlotStylePanelSave => self.on_plot_style_panel_save(), - Message::PlotStylePanelSavePath(Some(path)) => { + Message::PlotStylePanelSave => self.on_plot_style_panel_save(), + Message::PlotStylePanelSavePath(Some(path)) => { let path = if path .extension() .is_some_and(|extension| extension.eq_ignore_ascii_case("ctb")) @@ -8219,12 +8254,14 @@ impl OpenCADStudio { self.tabs[self.active_tab] .scene .invalidate_display_plot_style(); - self.command_line.push_output(crate::tf!( - "Plot style table saved to \"{}\".", - path.display() - ).as_ref()); + self.command_line.push_output( + crate::tf!("Plot style table saved to \"{}\".", path.display()) + .as_ref(), + ); } - Err(e) => self.command_line.push_error(crate::tf!("Save error: {e}").as_ref()), + Err(e) => self + .command_line + .push_error(crate::tf!("Save error: {e}").as_ref()), } } Task::none() @@ -8671,18 +8708,28 @@ impl OpenCADStudio { match field { "fill" => style.flags.fill_on = !style.flags.fill_on, "joints" => style.flags.display_joints = !style.flags.display_joints, - "start_square" => style.flags.start_square_cap = !style.flags.start_square_cap, - "start_inner" => style.flags.start_inner_arcs_cap = !style.flags.start_inner_arcs_cap, + "start_square" => { + style.flags.start_square_cap = !style.flags.start_square_cap + } + "start_inner" => { + style.flags.start_inner_arcs_cap = !style.flags.start_inner_arcs_cap + } "start_round" => style.flags.start_round_cap = !style.flags.start_round_cap, "end_square" => style.flags.end_square_cap = !style.flags.end_square_cap, - "end_inner" => style.flags.end_inner_arcs_cap = !style.flags.end_inner_arcs_cap, + "end_inner" => { + style.flags.end_inner_arcs_cap = !style.flags.end_inner_arcs_cap + } "end_round" => style.flags.end_round_cap = !style.flags.end_round_cap, _ => {} } } Task::none() } - Message::MlStyleElementEdit { index, field, value } => { + Message::MlStyleElementEdit { + index, + field, + value, + } => { if let Some(element) = self.mln_elements.get_mut(index) { match field { "offset" => element[0] = value, @@ -8832,12 +8879,11 @@ impl OpenCADStudio { let i = self.active_tab; self.tabs[i].scene.document.header.current_dimstyle_name = - self.dimstyle_selected.clone(); + self.dimstyle_selected.clone(); self.sync_ribbon_styles(); - self.command_line.push_output(crate::tf!( - "Current dim style set to '{}'.", - self.dimstyle_selected - ).as_ref()); + self.command_line.push_output( + crate::tf!("Current dim style set to '{}'.", self.dimstyle_selected).as_ref(), + ); Task::none() } Message::DimStyleDialogDelete => { @@ -8968,8 +9014,7 @@ impl OpenCADStudio { // are logical CAD states rather than reusable colours. if matches!( &color, - acadrust::types::Color::Index(_) - | acadrust::types::Color::Rgb { .. } + acadrust::types::Color::Index(_) | acadrust::types::Color::Rgb { .. } ) { // No duplicates: selecting an existing colour moves it to the front. if let Some(pos) = self.recent_colors.iter().position(|c| c == &color) { @@ -9243,7 +9288,10 @@ mod free_text_entry_tests { assert_eq!(app.command_line.input, "LIN"); // …and a pasted multi-token line runs as a command, not as text. let _ = app.update(Message::CommandInput("LINE 0,0 10,10".into())); - assert!(app.command_line.input.is_empty(), "Space submitted the line"); + assert!( + app.command_line.input.is_empty(), + "Space submitted the line" + ); assert_eq!(app.text_entry_mode(), TextEntryMode::Command); } } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c57bb947c..ae3372607 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -100,12 +100,10 @@ fn is_added_polyline_vertex( ) -> bool { match (original, current) { (AcadEntityType::LwPolyline(before), AcadEntityType::LwPolyline(after)) => { - after.vertices.len() == before.vertices.len() + 1 - && vertex_id < after.vertices.len() + after.vertices.len() == before.vertices.len() + 1 && vertex_id < after.vertices.len() } (AcadEntityType::Polyline2D(before), AcadEntityType::Polyline2D(after)) => { - after.vertices.len() == before.vertices.len() + 1 - && vertex_id < after.vertices.len() + after.vertices.len() == before.vertices.len() + 1 && vertex_id < after.vertices.len() } _ => false, } @@ -253,9 +251,7 @@ impl OpenCADStudio { eye: glam::DVec3, bounds: iced::Rectangle, ) -> Option { - let intersection = snap.filter(|hit| { - hit.snap_type == crate::snap::SnapType::Intersection - }); + let intersection = snap.filter(|hit| hit.snap_type == crate::snap::SnapType::Intersection); // Ordinary object snaps suppress OTRACK. Extension and Intersection are // exceptions: both may lie on an active tracking vector. @@ -269,22 +265,15 @@ impl OpenCADStudio { } let required_crossing_ray = match snap { - Some(extension) - if extension.snap_type == crate::snap::SnapType::Extension => - { - Some(( - extension.extension_origin?, - extension.extension_dir?, - )) + Some(extension) if extension.snap_type == crate::snap::SnapType::Extension => { + Some((extension.extension_origin?, extension.extension_dir?)) } _ => None, }; // When Intersection has already won OSNAP selection, test OTRACK at the // exact intersection rather than at the free cursor position. - let track_cursor = intersection - .map(|hit| hit.world) - .unwrap_or(cursor); + let track_cursor = intersection.map(|hit| hit.world).unwrap_or(cursor); let step = (self.polar_mode && drafting).then_some(self.polar_increment_deg); let (_, (ucs_x, ucs_y, _)) = self.drafting_grid_basis(tab); @@ -412,8 +401,8 @@ impl OpenCADStudio { if let Some((grid_on, _snap_on)) = self.tabs[i].scene.active_tile_grid_snap() { self.show_grid = grid_on; } - let ortho = self.tabs[i].scene.active_camera_projection() - == crate::scene::Projection::Orthographic; + let ortho = + self.tabs[i].scene.active_camera_projection() == crate::scene::Projection::Orthographic; self.ribbon.set_ortho(ortho); } @@ -498,9 +487,12 @@ impl OpenCADStudio { /// Reproject the cursor onto that profile before using it as a drag anchor /// or looking for its supporting solid face. fn profile_pick_point( - &self, i: usize, handle: Handle, + &self, + i: usize, + handle: Handle, edit_cam: &Option, - cursor: iced::Point, bounds: iced::Rectangle, + cursor: iced::Point, + bounds: iced::Rectangle, ) -> Option { let entity = self.tabs[i].scene.document.get_entity(handle)?; let (plane, _, _) = crate::scene::model::presspull_model::profile_geometry(entity)?; @@ -508,7 +500,11 @@ impl OpenCADStudio { let origin = glam::DVec3::from_array(plane.origin); let point = match edit_cam { Some(camera) => camera.pick_on_plane(cursor, bounds, normal, origin), - None => self.tabs[i].scene.camera.borrow().pick_on_plane(cursor, bounds, normal, origin), + None => self.tabs[i] + .scene + .camera + .borrow() + .pick_on_plane(cursor, bounds, normal, origin), }; point.is_finite().then_some(point) } @@ -574,9 +570,7 @@ impl OpenCADStudio { .iter() .copied() .zip(self.tabs[i].selected_grips.iter()) - .filter(|(owner, grip)| { - self.tabs[i].hot_grips.contains(&(*owner, grip.id)) - }) + .filter(|(owner, grip)| self.tabs[i].hot_grips.contains(&(*owner, grip.id))) .filter(|(_, grip)| grip.id != crate::app::visibility::VIS_GRIP_ID) .map(|(owner, grip)| GripTarget { handle: owner, @@ -600,9 +594,7 @@ impl OpenCADStudio { .copied() .zip(self.tabs[i].selected_grips.iter()) .filter(|(_, grip)| grip.id != crate::app::visibility::VIS_GRIP_ID) - .filter(|(_, grip)| { - (grip.world - world).length_squared() <= epsilon_sq - }) + .filter(|(_, grip)| (grip.world - world).length_squared() <= epsilon_sq) .map(|(owner, grip)| GripTarget { handle: owner, grip_id: grip.id, @@ -876,8 +868,7 @@ impl OpenCADStudio { // viewport's screen rectangle in a paper layout. let i = self.active_tab; if self.tabs[i].scene.active_viewport != expected_viewport - || (expected_viewport.is_none() - && self.tabs[i].scene.current_layout != "Model") + || (expected_viewport.is_none() && self.tabs[i].scene.current_layout != "Model") { return Task::none(); } @@ -1066,9 +1057,7 @@ impl OpenCADStudio { let zoom_delta = -dy * 0.03; if self.tabs[i].scene.active_viewport.is_some() { self.tabs[i].scene.pan_active_viewport(dx, 0.0, bounds); - self.tabs[i] - .scene - .zoom_active_viewport(zoom_delta, None); + self.tabs[i].scene.zoom_active_viewport(zoom_delta, None); } else { let local = iced::Point { x: p.x - bounds.x, @@ -1295,7 +1284,7 @@ impl OpenCADStudio { // dimension re-tessellates) and a Square insertion grip (so // an MTEXT width handle, a Triangle, still re-tessellates so // the re-wrap is exact). - let snap = self.tabs[i].scene.wire_models_for(&edited_handles); + let snap = self.tabs[i].scene.wire_models_for(&edited_handles); // Preserve the exact geometry from the instant the grip drag began. // This snapshot is visual/reference-only: do NOT append it to @@ -1303,12 +1292,8 @@ impl OpenCADStudio { self.grip_reference_wires = snap.clone(); // The engaged grip is always an intentional OTRACK reference. - self.snapper.acquire_grip_tracking_point( - grip.origin_world, - &self.grip_reference_wires, - ); - - + self.snapper + .acquire_grip_tracking_point(grip.origin_world, &self.grip_reference_wires); self.grip_text_verts = snap .iter() @@ -1319,9 +1304,7 @@ impl OpenCADStudio { .iter() .copied() .zip(self.tabs[i].selected_grips.iter()) - .find(|(owner, grip_def)| { - *owner == grip.handle && grip_def.id == grip.grip_id - }) + .find(|(owner, grip_def)| *owner == grip.handle && grip_def.id == grip.grip_id) .map(|(_, grip_def)| { grip_def.shape == crate::scene::model::object::GripShape::Square }) @@ -1384,37 +1367,36 @@ impl OpenCADStudio { // Its result is never used to place/move the grip. It exists only so the // user can hover an endpoint/midpoint/etc. of the original geometry and // acquire it for OTRACK exactly like ordinary drawing geometry. - let reference_snap_hit = if self.snapper.tracking_active() - && !self.grip_reference_wires.is_empty() - { - self.snapper - .snap( - raw, - p, - &self.grip_reference_wires, - view_rot, - eye, - bounds, - go, - gr, - None, - ) - .filter(|hit| { - matches!( - hit.snap_type, - crate::snap::SnapType::Endpoint - | crate::snap::SnapType::Midpoint - | crate::snap::SnapType::Center - | crate::snap::SnapType::Node - | crate::snap::SnapType::Quadrant - | crate::snap::SnapType::Intersection - | crate::snap::SnapType::Insertion - | crate::snap::SnapType::ApparentIntersection + let reference_snap_hit = + if self.snapper.tracking_active() && !self.grip_reference_wires.is_empty() { + self.snapper + .snap( + raw, + p, + &self.grip_reference_wires, + view_rot, + eye, + bounds, + go, + gr, + None, ) - }) - } else { - None - }; + .filter(|hit| { + matches!( + hit.snap_type, + crate::snap::SnapType::Endpoint + | crate::snap::SnapType::Midpoint + | crate::snap::SnapType::Center + | crate::snap::SnapType::Node + | crate::snap::SnapType::Quadrant + | crate::snap::SnapType::Intersection + | crate::snap::SnapType::Insertion + | crate::snap::SnapType::ApparentIntersection + ) + }) + } else { + None + }; // The visible marker should normally describe the real snap that is driving // the cursor. However, when there is no real object snap (or only Grid), show @@ -1432,12 +1414,9 @@ impl OpenCADStudio { // // IMPORTANT: this combined set is used ONLY for OTRACK acquisition. // It is never passed to the normal movement snap above. - let mut tracking_candidates: Vec<_> = - snap_candidates.iter().cloned().collect(); + let mut tracking_candidates: Vec<_> = snap_candidates.iter().cloned().collect(); - tracking_candidates.extend( - self.grip_reference_wires.iter().cloned() - ); + tracking_candidates.extend(self.grip_reference_wires.iter().cloned()); // Prefer a genuine drawing snap when it is an acquisition-capable point. // Otherwise let the frozen-reference snap drive the dwell acquisition. @@ -1467,16 +1446,7 @@ impl OpenCADStudio { ); let axis_lock = self.active_axis_lock(i, raw, base, true); let otrack_hit = if axis_lock.is_none() { - self.active_otrack_hit( - i, - raw, - snap_hit, - Some(base), - true, - view_rot, - eye, - bounds, - ) + self.active_otrack_hit(i, raw, snap_hit, Some(base), true, view_rot, eye, bounds) } else { None }; @@ -1486,9 +1456,7 @@ impl OpenCADStudio { let dir = (target - base).try_normalize()?; Some((base, dir)) }); - self.otrack_active = otrack_hit - .map(|hit| (hit.base, hit.dir)) - .or(drafting_guide); + self.otrack_active = otrack_hit.map(|hit| (hit.base, hit.dir)).or(drafting_guide); self.otrack_kind = otrack_hit.map(|hit| hit.kind); @@ -1535,28 +1503,20 @@ impl OpenCADStudio { if let Some(axis) = grip.axis { let acquired_point = otrack_hit.is_some() - || snap_hit.is_some_and(|hit| { - hit.snap_type != crate::snap::SnapType::Grid - }); + || snap_hit.is_some_and(|hit| hit.snap_type != crate::snap::SnapType::Grid); if acquired_point { if let Some(axis) = axis.try_normalize() { // An axis grip still moves on its construction axis, but // an acquired object point supplies the exact coordinate // along that axis. This lets height and draft arrows seat // back onto existing surface/solid geometry. - snapped = grip.origin_world - + axis * (snapped - grip.origin_world).dot(axis); + snapped = + grip.origin_world + axis * (snapped - grip.origin_world).dot(axis); } } else { - snapped = cursor_on_projected_axis( - p, - bounds, - view_rot, - eye, - grip.origin_world, - axis, - ) - .unwrap_or(snapped); + snapped = + cursor_on_projected_axis(p, bounds, view_rot, eye, grip.origin_world, axis) + .unwrap_or(snapped); } } @@ -1569,8 +1529,7 @@ impl OpenCADStudio { // Project the grip's original position into full-canvas coordinates. // Dynamic Input uses this as the polar Distance/Angle anchor. - let anchor_ndc = - view_rot.project_point3((grip.origin_world - eye).as_vec3()); + let anchor_ndc = view_rot.project_point3((grip.origin_world - eye).as_vec3()); self.tabs[i].last_point_screen = Some(Point::new( (anchor_ndc.x + 1.0) * 0.5 * bounds.width + tile_b.x, @@ -1607,9 +1566,7 @@ impl OpenCADStudio { GripEditMode::Lengthen => { Some(crate::scene::model::object::GripMenuAction::Lengthen) } - GripEditMode::Radius => { - Some(crate::scene::model::object::GripMenuAction::Radius) - } + GripEditMode::Radius => Some(crate::scene::model::object::GripMenuAction::Radius), GripEditMode::ArcLength => { Some(crate::scene::model::object::GripMenuAction::ArcLength) } @@ -1685,13 +1642,12 @@ impl OpenCADStudio { snapped, ); if let Some(value) = value { - let origin_value = - crate::scene::view::dispatch::grip_menu_point_value( - &original, - grip.grip_id, - action, - grip.origin_world, - ); + let origin_value = crate::scene::view::dispatch::grip_menu_point_value( + &original, + grip.grip_id, + action, + grip.origin_world, + ); let mut candidate = original.clone(); crate::entities::traits::EntityTypeOps::apply_grip_menu_value( &mut candidate, @@ -1722,10 +1678,8 @@ impl OpenCADStudio { // Arc point grips are coupled: moving one point must rebuild the // circle from the drag-start start/middle/end set, otherwise the // supposedly fixed points drift a little on every mouse event. - let mut arc_grip_edits: rustc_hash::FxHashMap< - Handle, - Vec<(usize, glam::DVec3)>, - > = rustc_hash::FxHashMap::default(); + let mut arc_grip_edits: rustc_hash::FxHashMap> = + rustc_hash::FxHashMap::default(); for (handle, grip_id, apply) in &actions { if let GripApply::Absolute(point) = apply { if (1..=3).contains(grip_id) { @@ -1749,12 +1703,8 @@ impl OpenCADStudio { let Some(current) = self.tabs[i].scene.document.get_entity_mut(handle) else { continue; }; - if crate::scene::view::dispatch::refit_arc_grips( - current, - &original, - &edits, - ) - .is_some() + if crate::scene::view::dispatch::refit_arc_grips(current, &original, &edits) + .is_some() { rebuilt_arcs.insert(handle); } @@ -1768,10 +1718,7 @@ impl OpenCADStudio { .iter() .find(|(handle, _)| *handle == target.handle) .map(|(_, entity)| entity)?; - let current = self.tabs[i] - .scene - .document - .get_entity(target.handle)?; + let current = self.tabs[i].scene.document.get_entity(target.handle)?; added_arc_bulge(original, current, target.grip_id) .map(|bulge| (target.handle, target.grip_id, bulge)) }) @@ -1792,11 +1739,14 @@ impl OpenCADStudio { } // Re-solve constrained neighbors on each drag frame and include // their original state in the gesture's undo record. - let solved_by_constraints = self.tabs[i].scene.solve_sketch_constraints_preview(&edited_handles); + let solved_by_constraints = self.tabs[i] + .scene + .solve_parametric_constraints_preview(&edited_handles); for (handle, _) in &solved_by_constraints { let handle = *handle; if !self.grip_preview_handles.contains(&handle) { - if let Some(original) = self.tabs[i].scene.document.get_entity(handle).cloned() { + if let Some(original) = self.tabs[i].scene.document.get_entity(handle).cloned() + { self.grip_originals.push((handle, original)); } self.grip_preview_handles.push(handle); @@ -1848,8 +1798,7 @@ impl OpenCADStudio { self.tabs[i].scene.set_preview_wires(Vec::new()); } else { // Current deformed geometry. - let mut preview = - self.tabs[i].scene.grip_wire_models_for(&edited_handles); + let mut preview = self.tabs[i].scene.grip_wire_models_for(&edited_handles); // Also show the drag-start geometry as a faint ghost. // @@ -1896,9 +1845,10 @@ impl OpenCADStudio { .collect(); if let Some(active) = self.tabs[i].active_grip.as_mut() { for target in &mut active.targets { - if let Some((_, world)) = refreshed_targets.iter().find(|((handle, id), _)| { - *handle == target.handle && *id == target.grip_id - }) { + if let Some((_, world)) = refreshed_targets + .iter() + .find(|((handle, id), _)| *handle == target.handle && *id == target.grip_id) + { target.last_world = *world; } } @@ -1942,15 +1892,14 @@ impl OpenCADStudio { // O(N) per frame and stalls the cursor on large drawings, // so each move resets the dwell timer — `HoverDwellTick` runs the hit-test only // once the cursor has been still for `HOVER_DWELL_MS`. - let deferred_command_hover = self.tabs[i].active_cmd.as_ref() - .is_some_and(|command| command.needs_entity_pick() && command.entity_pick_deferred_hover()); + let deferred_command_hover = self.tabs[i].active_cmd.as_ref().is_some_and(|command| { + command.needs_entity_pick() && command.entity_pick_deferred_hover() + }); if !dragging && (self.tabs[i].active_cmd.is_none() || deferred_command_hover) { // On dense drawings, clearing a rollover immediately schedules a // second full scene frame just as motion resumes. Keep the previous // highlight until the next settled pick replaces it. - if self.tabs[i].scene.last_tess_wires.get() - < crate::app::HOVER_DWELL_DENSE_WIRES - { + if self.tabs[i].scene.last_tess_wires.get() < crate::app::HOVER_DWELL_DENSE_WIRES { self.tabs[i].scene.set_hover_highlight(None); } self.hover_dwell = Some(crate::app::HoverDwell { @@ -2065,14 +2014,7 @@ impl OpenCADStudio { None } else { self.last_point.and_then(|base| { - self.active_construction_ray( - i, - snap_cursor, - base, - view_rot, - eye, - bounds, - ) + self.active_construction_ray(i, snap_cursor, base, view_rot, eye, bounds) }) }; @@ -2190,10 +2132,7 @@ impl OpenCADStudio { // the point onto the line through last_point parallel to the // acquired reference, and drive the alignment guide off it. // (#277) - if axis_lock.is_none() - && self.tabs[i].snap_result.is_none() - && otrack_hit.is_none() - { + if axis_lock.is_none() && self.tabs[i].snap_result.is_none() && otrack_hit.is_none() { if let Some(par) = self.snapper.parallel_snap( cursor_world.as_vec3(), self.last_point.map(|p| p.as_vec3()), @@ -2211,54 +2150,53 @@ impl OpenCADStudio { } let effective = { - let mut pt: glam::DVec3 = if let (Some(dir), Some(base)) = - (axis_lock, self.last_point) - { - let point = self.tabs[i] - .snap_result - .map(|snap| snap.world) - .unwrap_or(cursor_world); - axis_lock_apply(point, base, dir) - } else if let Some(h) = otrack_hit { - h.aligned - } else { - // Snap runs in model space (viewport camera or the - // model/paper view), so the result is already model. - let mut pt = self.tabs[i] - .snap_result - .map(|s| s.world) - .unwrap_or(cursor_world); - let osnap_locked = self.tabs[i] - .snap_result - .is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid); - if !osnap_locked && !is_window_corner && !uses_command_cursor_plane { - if let Some(base) = self.last_point { - let ucs_xf = self.tabs[i].ucs_xform(); - if self.ortho_mode { - pt = drafting_constrain( - pt, - base, - &ucs_xf, - self.isometric_drafting, - self.iso_plane, - self.snap_angle_deg, - ); - } else if self.polar_mode { - pt = polar_constrain_near( - pt, - base, - self.polar_increment_deg, - view_rot, - eye, - bounds, - self.snapper.osnap_radius_px, - &ucs_xf, - ); + let mut pt: glam::DVec3 = + if let (Some(dir), Some(base)) = (axis_lock, self.last_point) { + let point = self.tabs[i] + .snap_result + .map(|snap| snap.world) + .unwrap_or(cursor_world); + axis_lock_apply(point, base, dir) + } else if let Some(h) = otrack_hit { + h.aligned + } else { + // Snap runs in model space (viewport camera or the + // model/paper view), so the result is already model. + let mut pt = self.tabs[i] + .snap_result + .map(|s| s.world) + .unwrap_or(cursor_world); + let osnap_locked = self.tabs[i] + .snap_result + .is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid); + if !osnap_locked && !is_window_corner && !uses_command_cursor_plane { + if let Some(base) = self.last_point { + let ucs_xf = self.tabs[i].ucs_xform(); + if self.ortho_mode { + pt = drafting_constrain( + pt, + base, + &ucs_xf, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, + ); + } else if self.polar_mode { + pt = polar_constrain_near( + pt, + base, + self.polar_increment_deg, + view_rot, + eye, + bounds, + self.snapper.osnap_radius_px, + &ucs_xf, + ); + } } } - } - pt - }; + pt + }; // Clamp to world XY only when no UCS is active; with a // UCS the point already lies on the UCS XY plane. if self.tabs[i].active_cmd.is_some() @@ -2269,25 +2207,31 @@ impl OpenCADStudio { } pt }; - let effective = if needs_entity && self.tabs[i].active_cmd.as_ref() - .is_some_and(|command| command.entity_pick_accepts_points()) + let effective = if needs_entity + && self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.entity_pick_accepts_points()) { cursor_world - } else { self.tabs[i] - .active_cmd - .as_ref() - .and_then(|command| command.cursor_axis()) - .and_then(|(origin, direction)| { - cursor_on_projected_axis(p, bounds, view_rot, eye, origin, direction) - }) - .unwrap_or(effective) }; + } else { + self.tabs[i] + .active_cmd + .as_ref() + .and_then(|command| command.cursor_axis()) + .and_then(|(origin, direction)| { + cursor_on_projected_axis(p, bounds, view_rot, eye, origin, direction) + }) + .unwrap_or(effective) + }; // Dynamic-input locked fields constrain the preview point // (#356): a typed angle pins the direction, a typed // distance pins the radius — the same resolution the Enter // commit uses, so preview and commit agree. The lock wins // over osnap/ortho/polar. let effective = { - let locked = !needs_entity && self.tabs[i].active_cmd.is_some() + let locked = !needs_entity + && self.tabs[i].active_cmd.is_some() && self.tabs[i].dyn_fields.iter().any(|f| f.buffer.is_some()); if locked { self.tabs[i].last_cursor_world = effective; @@ -2485,10 +2429,17 @@ impl OpenCADStudio { self.tabs[i].scene.set_hover_highlight(None); } let hover_handle = hovered.unwrap_or(acadrust::Handle::NULL); - let wants_entity = self.tabs[i].active_cmd.as_ref() + let wants_entity = self.tabs[i] + .active_cmd + .as_ref() .is_some_and(|command| command.wants_hover_entity(hover_handle)); if wants_entity { - if let Some(entity) = self.tabs[i].scene.document.get_entity(hover_handle).cloned() { + if let Some(entity) = self.tabs[i] + .scene + .document + .get_entity(hover_handle) + .cloned() + { if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.inject_hover_entity(hover_handle, entity); } @@ -2636,8 +2587,7 @@ impl OpenCADStudio { self.sync_dyn_fields(); let move_ms = move_started.elapsed().as_secs_f64() * 1000.0; if perf_move - && (self.tabs[i].active_cmd.is_some() - || self.tabs[i].scene.active_viewport.is_some()) + && (self.tabs[i].active_cmd.is_some() || self.tabs[i].scene.active_viewport.is_some()) && move_ms >= 16.7 { let mode = if self.tabs[i].active_cmd.is_some() { @@ -2645,10 +2595,7 @@ impl OpenCADStudio { } else { "MSPACE" }; - crate::perf_record!( - "[perf] pointer-move mode={mode:<7} {:>7.1}ms", - move_ms, - ); + crate::perf_record!("[perf] pointer-move mode={mode:<7} {:>7.1}ms", move_ms,); } Task::none() } @@ -2656,7 +2603,9 @@ impl OpenCADStudio { pub(crate) fn on_viewport_exit(&mut self) -> Task { let i = self.active_tab; self.hover_dwell = None; - if self.tabs[i].active_cmd.as_ref() + if self.tabs[i] + .active_cmd + .as_ref() .is_some_and(|command| command.entity_pick_deferred_hover()) { self.tabs[i].scene.clear_preview_wire(); @@ -2805,9 +2754,17 @@ impl OpenCADStudio { // No rubber-band origin (perp/extension feet don't apply to a free drag). self.snapper.from_point = None; let (go, gr) = self.drafting_grid_basis(i); - let snap_hit = self - .snapper - .snap(raw, p, &snap_candidates, view_rot, eye, bounds, go, gr, None); + let snap_hit = self.snapper.snap( + raw, + p, + &snap_candidates, + view_rot, + eye, + bounds, + go, + gr, + None, + ); let world = snap_hit.map(|s| s.world).unwrap_or(raw); self.tabs[i].snap_result = snap_hit; if let Some(s) = self.tabs[i].snap_result.as_mut() { @@ -2868,9 +2825,8 @@ impl OpenCADStudio { /// its transient origin and axes are baked into the block definition, then /// the editor returns to canonical local coordinates. pub(in crate::app) fn commit_active_ucs_change(&mut self, i: usize, label: &'static str) { - if let Some((session_index, block_record)) = self.tabs[i] - .active_block_edit - .and_then(|index| { + if let Some((session_index, block_record)) = + self.tabs[i].active_block_edit.and_then(|index| { self.tabs[i] .block_edits .get(index) @@ -2917,9 +2873,8 @@ impl OpenCADStudio { } else { None }; - let same_basis = |a: Option<&acadrust::tables::Ucs>, - b: Option<&acadrust::tables::Ucs>| { - match (a, b) { + let same_basis = + |a: Option<&acadrust::tables::Ucs>, b: Option<&acadrust::tables::Ucs>| match (a, b) { (None, None) => true, (Some(a), Some(b)) => { a.origin == b.origin @@ -2929,8 +2884,7 @@ impl OpenCADStudio { && a.name.eq_ignore_ascii_case(&b.name) } _ => false, - } - }; + }; if !same_basis(self.tabs[i].active_ucs.as_ref(), persisted.as_ref()) { self.push_undo_snapshot(i, label); self.tabs[i].persist_active_ucs(); @@ -3065,10 +3019,7 @@ impl OpenCADStudio { // Interactive navigation tools reuse the middle-button movement path, // so no selection/pick logic runs while the left button drives them. - if self.tabs[i].orbit_mode - || self.tabs[i].pan_mode - || self.tabs[i].zoom_dynamic_mode - { + if self.tabs[i].orbit_mode || self.tabs[i].pan_mode || self.tabs[i].zoom_dynamic_mode { self.clear_navigation_hover(i); self.tabs[i].scene.remember_current_view(); let mut sel = self.tabs[i].scene.selection.borrow_mut(); @@ -3247,13 +3198,8 @@ impl OpenCADStudio { self.grip_popup = None; return Task::none(); } - self.tabs[i].active_grip = Some(self.grip_edit_for_hit( - i, - handle, - grip_id, - is_translate, - world, - )); + self.tabs[i].active_grip = + Some(self.grip_edit_for_hit(i, handle, grip_id, is_translate, world)); self.grip_hover = None; self.grip_popup = None; self.tabs[i] @@ -3295,8 +3241,13 @@ impl OpenCADStudio { .and_then(scene::pe_url_of) .and_then(crate::sys::web_hyperlink); if let Some(url) = url { - self.tabs[i].scene.selection.borrow_mut().clear_left_selection_gesture(); - self.command_line.push_info(&format!("{}: {url}", crate::t!("Hyperlink"))); + self.tabs[i] + .scene + .selection + .borrow_mut() + .clear_left_selection_gesture(); + self.command_line + .push_info(&format!("{}: {url}", crate::t!("Hyperlink"))); return crate::sys::open_url(&url, self.main_window); } } @@ -3321,10 +3272,7 @@ impl OpenCADStudio { // Navigation mode: end this drag but keep the tool armed for the next // left drag (exit is Esc / another command). Mirror of the press. - if self.tabs[i].orbit_mode - || self.tabs[i].pan_mode - || self.tabs[i].zoom_dynamic_mode - { + if self.tabs[i].orbit_mode || self.tabs[i].pan_mode || self.tabs[i].zoom_dynamic_mode { let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.middle_down = false; sel.middle_last_pos = None; @@ -3381,8 +3329,11 @@ impl OpenCADStudio { } if matches!( grip.mode, - GripEditMode::Lengthen | GripEditMode::Radius | GripEditMode::ArcLength - | GripEditMode::RectangleWidth | GripEditMode::RectangleHeight + GripEditMode::Lengthen + | GripEditMode::Radius + | GripEditMode::ArcLength + | GripEditMode::RectangleWidth + | GripEditMode::RectangleHeight | GripEditMode::MoveParallel ) { self.grip_pending = None; @@ -3394,17 +3345,23 @@ impl OpenCADStudio { .iter() .find(|(handle, _)| *handle == target.handle) .map(|(_, entity)| entity)?; - let current = self.tabs[i] - .scene - .document - .get_entity(target.handle)?; + let current = self.tabs[i].scene.document.get_entity(target.handle)?; is_added_polyline_vertex(original, current, target.grip_id) .then_some(target.grip_id) }); // Keep originals available until every history shape has a valid // final display. A rejected rebuild cancels the entire gesture. - let history_handles: Vec<_> = self.grip_preview_handles.iter().copied() - .filter(|handle| self.tabs[i].scene.document.solid_history_operation(*handle).is_some()) + let history_handles: Vec<_> = self + .grip_preview_handles + .iter() + .copied() + .filter(|handle| { + self.tabs[i] + .scene + .document + .solid_history_operation(*handle) + .is_some() + }) .collect(); for handle in history_handles { if !self.tabs[i].scene.finalize_solid_history(handle) { @@ -3493,12 +3450,7 @@ impl OpenCADStudio { .as_ref() .map(|c| c.is_selection_gathering()) .unwrap_or(false); - let selection_box_active = self.tabs[i] - .scene - .selection - .borrow() - .box_anchor - .is_some(); + let selection_box_active = self.tabs[i].scene.selection.borrow().box_anchor.is_some(); let selection_pick_add = self.pick_add || self.tabs[i] .active_cmd @@ -3742,7 +3694,8 @@ impl OpenCADStudio { // A click while dynamic-input fields hold typed values // commits the CONSTRAINED point — the same resolution // the preview shows and Enter would commit (#356). - if !needs_entity_click && self.tabs[i].active_cmd.is_some() + if !needs_entity_click + && self.tabs[i].active_cmd.is_some() && self.tabs[i].dyn_fields.iter().any(|f| f.buffer.is_some()) { self.tabs[i].last_cursor_world = pt; @@ -3750,8 +3703,11 @@ impl OpenCADStudio { pt = r; } } - if needs_entity_click && self.tabs[i].active_cmd.as_ref() - .is_some_and(|command| command.entity_pick_accepts_points()) + if needs_entity_click + && self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.entity_pick_accepts_points()) { raw } else { @@ -3890,45 +3846,59 @@ impl OpenCADStudio { self.tabs[i] .scene .solid_click_point_for(p, view_rot2, eye2, bounds, handle) - .or_else(|| self.tabs[i].active_cmd.as_ref() - .is_some_and(|command| command.entity_pick_deferred_hover()) - .then(|| self.profile_pick_point(i, handle, &edit_cam, p, bounds)).flatten()) + .or_else(|| { + self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.entity_pick_deferred_hover()) + .then(|| { + self.profile_pick_point(i, handle, &edit_cam, p, bounds) + }) + .flatten() + }) .unwrap_or_else(|| { - let bounded_pick = self.tabs[i].active_cmd.as_ref() + let bounded_pick = self.tabs[i] + .active_cmd + .as_ref() .is_some_and(|command| command.entity_pick_deferred_hover()); - if bounded_pick && matches!( - self.tabs[i].scene.document.get_entity(handle), - Some(acadrust::EntityType::Solid3D(_)), - ) { + if bounded_pick + && matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Solid3D(_)), + ) + { // The aperture caught an edge outside its face. // Do not reinterpret the working-plane point as // a hit on another cap of the same body. glam::DVec3::NAN - } else { pick_wcs } + } else { + pick_wcs + } }) } else { pick_wcs }; - let entity_pick_direction = if uses_surface_point && entity_pick_point.is_finite() { - if matches!( - self.tabs[i].scene.document.get_entity(handle), - Some(acadrust::EntityType::Solid3D(_)) - ) { - self.tabs[i] - .scene - .solid_planar_face_normal_at(handle, entity_pick_point) + let entity_pick_direction = + if uses_surface_point && entity_pick_point.is_finite() { + if matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Solid3D(_)) + ) { + self.tabs[i] + .scene + .solid_planar_face_normal_at(handle, entity_pick_point) + } else { + self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(crate::entities::curve::entity_curve) + .and_then(|curve| curve.plane.normal()) + .map(glam::DVec3::from_array) + } } else { - self.tabs[i] - .scene - .document - .get_entity(handle) - .and_then(crate::entities::curve::entity_curve) - .and_then(|curve| curve.plane.normal()) - .map(glam::DVec3::from_array) - } - } else { - None - }; + None + }; // Some commands (e.g. SS_CATCHMENT) need the entity // body before `on_entity_pick` can advance. let inject_first = self.tabs[i] @@ -3972,11 +3942,11 @@ impl OpenCADStudio { if let Some(model) = self.tabs[i].scene.hatches.get(&handle).cloned() { let entity = self.tabs[i].scene.document.get_entity(handle); let annotative = entity.is_some_and(|entity| { - crate::scene::annotative::is_annotative( - &self.tabs[i].scene.document, - entity, - ) - }); + crate::scene::annotative::is_annotative( + &self.tabs[i].scene.document, + entity, + ) + }); let (scale, angle) = match entity { Some(acadrust::EntityType::Hatch(hatch)) => ( hatch.pattern_scale as f32, @@ -4032,11 +4002,16 @@ impl OpenCADStudio { } } result - } else if self.tabs[i].active_cmd.as_ref() + } else if self.tabs[i] + .active_cmd + .as_ref() .is_some_and(|command| command.entity_pick_accepts_points()) { self.refresh_command_point_pick_context(i); - self.tabs[i].active_cmd.as_mut().map(|command| command.on_point(pick_wcs)) + self.tabs[i] + .active_cmd + .as_mut() + .map(|command| command.on_point(pick_wcs)) } else if self.tabs[i] .active_cmd .as_ref() @@ -4068,7 +4043,8 @@ impl OpenCADStudio { .as_mut() .map(|c| c.on_tangent_point(obj, pick_wcs)) } else { - self.command_line.push_info(crate::t!("Select a tangent object.").as_ref()); + self.command_line + .push_info(crate::t!("Select a tangent object.").as_ref()); None } } else if !self.command_point_allowed(i, world_pt) { @@ -4196,8 +4172,7 @@ impl OpenCADStudio { }) .collect(); if points.last().is_none_or(|last| { - (last.x - p.x).abs() > f32::EPSILON - || (last.y - p.y).abs() > f32::EPSILON + (last.x - p.x).abs() > f32::EPSILON || (last.y - p.y).abs() > f32::EPSILON }) { points.push(p); } @@ -4307,8 +4282,7 @@ impl OpenCADStudio { eye, bounds, ); - let cand_ms = t_sel - .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let cand_ms = t_sel.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); let t_hit = crate::perf::enabled().then(Instant::now); let candidate_handles = self.tabs[i] .scene @@ -4370,8 +4344,7 @@ impl OpenCADStudio { // selection, Shift+box removes the boxed // entities. Esc / empty-space click still clears. // PICKADD 0 (#226): a plain box REPLACES. - let hit_ms = t_hit - .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let hit_ms = t_hit.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); let t_apply = crate::perf::enabled().then(Instant::now); if self.shift_down || self.select_remove_mode { self.tabs[i].scene.deselect_entities(&handles); @@ -4382,8 +4355,7 @@ impl OpenCADStudio { self.tabs[i].scene.select_entities(&handles); self.tabs[i].scene.expand_selection_for_groups(&handles); } - let apply_ms = t_apply - .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let apply_ms = t_apply.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); let t_props = crate::perf::enabled().then(Instant::now); self.refresh_properties(); if crate::perf::enabled() { @@ -4642,10 +4614,7 @@ was_selected={}", ); let command_result = self.tabs[i].active_cmd.as_mut().and_then(|command| { command.set_shift(self.shift_down); - command.on_drag_selection( - &command_fence, - Some((command_min, command_max)), - ) + command.on_drag_selection(&command_fence, Some((command_min, command_max))) }); if let Some(result) = command_result { let mut selection = self.tabs[i].scene.selection.borrow_mut(); @@ -4694,8 +4663,7 @@ was_selected={}", eye, bounds, ); - let cand_ms = - t_sel.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let cand_ms = t_sel.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); let t_hit = crate::perf::enabled().then(Instant::now); let candidate_handles = self.tabs[i] .scene @@ -4762,8 +4730,7 @@ was_selected={}", let t_filter = crate::perf::enabled().then(Instant::now); // Selection filter: keep only allowed types. handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h)); - let filter_ms = - t_filter.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let filter_ms = t_filter.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); let t_apply = crate::perf::enabled().then(Instant::now); // Accumulate (issue #83): a plain box adds to the // current selection, Shift+box removes the boxed @@ -4779,8 +4746,7 @@ was_selected={}", self.tabs[i].scene.select_entities(&handles); self.tabs[i].scene.expand_selection_for_groups(&handles); } - let apply_ms = - t_apply.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let apply_ms = t_apply.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); let t_props = crate::perf::enabled().then(Instant::now); self.refresh_properties(); if crate::perf::enabled() { @@ -4902,9 +4868,12 @@ properties={:.1}ms picked={}", // Locked layer: double-click must not open any editor // (text / attribute / in-place block edit). if let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) { - self.command_line.push_info(crate::tf!( + self.command_line.push_info( + crate::tf!( "Object is on locked layer \"{layer}\" — unlock the layer to edit it." - ).as_ref()); + ) + .as_ref(), + ); return Task::none(); } // Table double-click: shared TABLEDIT editor setup. A @@ -5055,10 +5024,7 @@ properties={:.1}ms picked={}", if let Some(AcadEntityType::Viewport(vp)) = self.tabs[i].scene.document.get_entity(h) { - if !Scene::is_sheet_viewport( - &self.tabs[i].scene.document, - vp, - ) { + if !Scene::is_sheet_viewport(&self.tabs[i].scene.document, vp) { Some(h) } else { None @@ -5125,8 +5091,11 @@ properties={:.1}ms picked={}", /// ends. Repeated wheel events replace this timestamp, so the pick runs only /// after the final zoom step settles. pub(in crate::app) fn arm_hover_after_navigation(&mut self, i: usize) { - if i >= self.tabs.len() || self.tabs[i].active_cmd.as_ref() - .is_some_and(|command| !command.entity_pick_deferred_hover()) + if i >= self.tabs.len() + || self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| !command.entity_pick_deferred_hover()) { return; } @@ -5180,7 +5149,8 @@ properties={:.1}ms picked={}", self.tabs[i] .scene .record_nav_perf(crate::scene::NavPerfOp::Zoom, now); - self.command_line.push_output(crate::t!("Zoom Extents").as_ref()); + self.command_line + .push_output(crate::t!("Zoom Extents").as_ref()); } Task::none() } @@ -5285,8 +5255,7 @@ properties={:.1}ms picked={}", ) -> Task { let i = self.active_tab; if self.tabs[i].scene.active_viewport != expected_viewport - || (expected_viewport.is_none() - && self.tabs[i].scene.current_layout != "Model") + || (expected_viewport.is_none() && self.tabs[i].scene.current_layout != "Model") { return Task::none(); } @@ -5427,8 +5396,9 @@ properties={:.1}ms picked={}", command.set_ctrl(self.ctrl_down); command.set_shift(self.shift_down); } - let deferred_command = self.tabs[i].active_cmd.as_ref() - .is_some_and(|command| command.needs_entity_pick() && command.entity_pick_deferred_hover()); + let deferred_command = self.tabs[i].active_cmd.as_ref().is_some_and(|command| { + command.needs_entity_pick() && command.entity_pick_deferred_hover() + }); let navigating = self.tabs[i].scene.selection.borrow().middle_down; if (self.tabs[i].active_cmd.is_some() && !deferred_command) || navigating { self.clear_navigation_hover(i); @@ -5521,27 +5491,40 @@ properties={:.1}ms picked={}", } if hovered.is_none() { let started = Instant::now(); - hovered = if deferred_command { self.tabs[i].scene.solid_click_hit( - p, view_rot, eye, bounds, candidate_handles.as_ref(), - ) } else { self.tabs[i].scene.solid_edge_hover_hit( - p, - view_rot, - eye, - bounds, - candidate_handles.as_ref(), - crate::ui::overlay::pick_box_aperture_px(self.pick_box), - ) }; + hovered = if deferred_command { + self.tabs[i].scene.solid_click_hit( + p, + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + ) + } else { + self.tabs[i].scene.solid_edge_hover_hit( + p, + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + crate::ui::overlay::pick_box_aperture_px(self.pick_box), + ) + }; solid_ms = started.elapsed().as_secs_f64() * 1000.0; } if deferred_command { let surface_point = hovered.and_then(|handle| { - self.tabs[i].scene.solid_click_point_for(p, view_rot, eye, bounds, handle) + self.tabs[i] + .scene + .solid_click_point_for(p, view_rot, eye, bounds, handle) .or_else(|| self.profile_pick_point(i, handle, &edit_cam, p, bounds)) }); let point = surface_point.unwrap_or(hover_world); - let is_solid = hovered.is_some_and(|handle| matches!( - self.tabs[i].scene.document.get_entity(handle), Some(acadrust::EntityType::Solid3D(_)), - )); + let is_solid = hovered.is_some_and(|handle| { + matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Solid3D(_)), + ) + }); // An aperture may catch an edge beside, rather than on, a face. // Never send its XY-plane fallback as a 3D face pick. if is_solid && surface_point.is_none() { @@ -5550,17 +5533,26 @@ properties={:.1}ms picked={}", self.hover_dwell = None; return Task::none(); } - let handles = self.tabs[i].scene.document.entities().filter_map(|entity| { - matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle) - }).collect::>(); + let handles = self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + matches!(entity, acadrust::EntityType::Solid3D(_)) + .then_some(entity.common().handle) + }) + .collect::>(); self.tabs[i].scene.restore_solid_models(&handles); let show = (self.model_space.selection_preview & 2) != 0; let previews = if show { let tab = &mut self.tabs[i]; - tab.active_cmd.as_mut().map(|command| { - command.on_deferred_entity_hover(&tab.scene, hovered, point) - }).unwrap_or_default() - } else { Vec::new() }; + tab.active_cmd + .as_mut() + .map(|command| command.on_deferred_entity_hover(&tab.scene, hovered, point)) + .unwrap_or_default() + } else { + Vec::new() + }; self.tabs[i].scene.set_hover_highlight(None); self.tabs[i].scene.set_preview_wires(previews); self.hover_dwell = None; @@ -5705,10 +5697,7 @@ properties={:.1}ms picked={}", /// MVIEW's "Insert view > New" flow deliberately visits Model space to /// define a view and then returns to its paper layout. This is the only /// layout transition allowed to preserve an active command. - pub(crate) fn on_layout_switch_preserving_command( - &mut self, - name: String, - ) -> Task { + pub(crate) fn on_layout_switch_preserving_command(&mut self, name: String) -> Task { self.on_layout_switch_inner(name, true) } @@ -5859,11 +5848,7 @@ properties={:.1}ms picked={}", .header .paper_space_linetype_scaling, ) | (i16::from( - self.tabs[i] - .scene - .document - .header - .paper_space_limit_check, + self.tabs[i].scene.document.header.paper_space_limit_check, ) << 1); let plot_style = self .active_plot_style @@ -5901,9 +5886,10 @@ properties={:.1}ms picked={}", self.tabs[i].scene.ensure_sheet_viewport(&new_name); let switch_task = self.on_layout_switch(new_name.clone()); self.tabs[i].scene.fit_all(); - self.command_line.push_output(crate::tf!( - "Layout \"{new_name}\" created — use MVIEW to add a viewport" - ).as_ref()); + self.command_line.push_output( + crate::tf!("Layout \"{new_name}\" created — use MVIEW to add a viewport") + .as_ref(), + ); self.tabs[i].dirty = true; return Task::batch([cancel_task, switch_task]); } @@ -5932,14 +5918,13 @@ properties={:.1}ms picked={}", .get(&new_name) .is_some() { - self.command_line - .push_error(crate::tf!("\"{}\" name already in use", new_name).as_ref()); + self.command_line.push_error( + crate::tf!("\"{}\" name already in use", new_name).as_ref(), + ); } else { self.push_undo_snapshot(i, "BLOCK RENAME"); if self.tabs[i].scene.rename_block(&orig, &new_name) { - if let Some(session) = - self.tabs[i].active_block_edit_session_mut() - { + if let Some(session) = self.tabs[i].active_block_edit_session_mut() { session.block_name = new_name.clone(); } for session in &mut self.tabs[i].block_edits { @@ -5948,11 +5933,13 @@ properties={:.1}ms picked={}", } } self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("Block \"{orig}\" → \"{new_name}\"").as_ref()); + self.command_line.push_output( + crate::tf!("Block \"{orig}\" → \"{new_name}\"").as_ref(), + ); } else { - self.command_line - .push_error(crate::tf!("Could not rename block \"{orig}\"").as_ref()); + self.command_line.push_error( + crate::tf!("Could not rename block \"{orig}\"").as_ref(), + ); } } return Task::none(); @@ -5988,8 +5975,8 @@ mod selection_preview_tests { #[test] fn grip_moves_keep_perpendicular_constraints_live_and_undoable() { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; use acadrust::{entities::Line, types::Vector3, EntityType}; - use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); @@ -6000,37 +5987,58 @@ mod selection_preview_tests { app.ortho_mode = false; app.polar_mode = false; app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); - let a = app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points( - Vector3::new(-10.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0), - ))); - let b = app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points( - Vector3::new(15.0, -10.0, 0.0), Vector3::new(15.0, 10.0, 0.0), - ))); - let _ = app.apply_cmd_result(crate::command::CmdResult::AddSketchConstraint { + let a = app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::new(-10.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 0.0), + ))); + let b = app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::new(15.0, -10.0, 0.0), + Vector3::new(15.0, 10.0, 0.0), + ))); + let _ = app.apply_cmd_result(crate::command::CmdResult::AddParametricConstraint { kind: ConstraintKind::Perpendicular, - refs: vec![SketchRef::whole(a), SketchRef::whole(b)], + refs: vec![ParametricRef::whole(a), ParametricRef::whole(b)], driving_param: None, label: "Perpendicular constraint", }); - let geometry = |app: &OpenCADStudio| [a, b].map(|handle| { - match app.tabs[i].scene.document.get_entity(handle).unwrap() { - EntityType::Line(line) => [line.start, line.end], - _ => panic!("expected line"), - } - }); + let geometry = |app: &OpenCADStudio| { + [a, b].map( + |handle| match app.tabs[i].scene.document.get_entity(handle).unwrap() { + EntityType::Line(line) => [line.start, line.end], + _ => panic!("expected line"), + }, + ) + }; let before = geometry(&app); - app.tabs[i].active_grip = Some(GripEdit::single(a, 1, false, glam::DVec3::new(10.0, 0.0, 0.0))); + app.tabs[i].active_grip = Some(GripEdit::single( + a, + 1, + false, + glam::DVec3::new(10.0, 0.0, 0.0), + )); for cursor in [Point::new(520.0, 220.0), Point::new(490.0, 180.0)] { let _ = app.on_viewport_move(cursor); let current = geometry(&app); for point in current.iter().flatten() { assert_eq!(point.z, 0.0, "grip left the drawing plane: {point:?}"); } - let direction = |ends: [Vector3; 2]| glam::DVec3::new( - ends[1].x - ends[0].x, ends[1].y - ends[0].y, ends[1].z - ends[0].z, - ).normalize(); + let direction = |ends: [Vector3; 2]| { + glam::DVec3::new( + ends[1].x - ends[0].x, + ends[1].y - ends[0].y, + ends[1].z - ends[0].z, + ) + .normalize() + }; assert!(direction(current[0]).dot(direction(current[1])).abs() < 1e-8); - assert_ne!(current[1], before[1], "constrained neighbor must follow each grip frame"); + assert_ne!( + current[1], before[1], + "constrained neighbor must follow each grip frame" + ); } let _ = app.on_viewport_left_release(); assert!(app.tabs[i].active_grip.is_none()); @@ -6044,10 +6052,10 @@ mod selection_preview_tests { #[cfg(target_os = "linux")] #[test] fn hyperlink_click_respects_existing_pointer_owners() { - use acadrust::EntityType; use acadrust::entities::Line; use acadrust::types::Vector3; use acadrust::xdata::{ExtendedDataRecord, XDataValue}; + use acadrust::EntityType; use glam::DVec3; for mode in ["link", "plain", "pan", "orbit", "zoom", "grip", "command"] { @@ -6057,10 +6065,8 @@ mod selection_preview_tests { app.show_ucs_icon = false; // Keep the browser-opening window task deferred during this test. app.main_window = Some(iced::window::Id::unique()); - let mut line = Line::from_points( - Vector3::new(-10.0, 0.0, 0.0), - Vector3::new(10.0, 0.0, 0.0), - ); + let mut line = + Line::from_points(Vector3::new(-10.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); let mut record = ExtendedDataRecord::new("PE_URL"); record.add_value(XDataValue::String("https://example.com/linked".into())); line.common.extended_data.add_record(record); @@ -6073,12 +6079,20 @@ mod selection_preview_tests { "pan" => app.tabs[i].pan_mode = true, "orbit" => app.tabs[i].orbit_mode = true, "zoom" => app.tabs[i].zoom_dynamic_mode = true, - "grip" => app.tabs[i].active_grip = Some(GripEdit::single(handle, 0, false, DVec3::ZERO)), - "command" => { let _ = app.run_command_line("LINE"); } + "grip" => { + app.tabs[i].active_grip = Some(GripEdit::single(handle, 0, false, DVec3::ZERO)) + } + "command" => { + let _ = app.run_command_line("LINE"); + } _ => {} } let task = app.on_viewport_left_press(); - let opened = app.command_line.history.iter().any(|entry| entry.text.contains("https://example.com/linked")); + let opened = app + .command_line + .history + .iter() + .any(|entry| entry.text.contains("https://example.com/linked")); assert_eq!(opened, mode == "link", "{mode}"); if mode == "link" { assert!(task.units() > 0); @@ -6124,14 +6138,8 @@ mod selection_preview_tests { rollover_hits(1), "bit 1 set: the line under the cursor must be highlighted", ); - assert!( - rollover_hits(3), - "both bits set: still highlighted", - ); - assert!( - !rollover_hits(0), - "preview off: nothing may be highlighted", - ); + assert!(rollover_hits(3), "both bits set: still highlighted",); + assert!(!rollover_hits(0), "preview off: nothing may be highlighted",); assert!( !rollover_hits(2), "only the in-command bit: the idle rollover stays off", diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 285905fcb..bd48e6260 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -5,6 +5,7 @@ use super::{ArrowKey, Message, OpenCADStudio}; use crate::scene::pick::grip::{grips_to_screen, grips_to_screen_paper, grips_to_screen_rte}; use crate::scene::view::viewport_pane::ViewportPane; use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX}; +use crate::t; use crate::ui::wrap_bar::DensitySwap; use crate::ui::wrap_bar::WrapFlow; use iced::widget::{ @@ -14,7 +15,6 @@ use iced::widget::{ use iced::window; use iced::{keyboard, Background, Border, Color, Element, Fill, Length, Subscription, Task, Theme}; use iced_aw::ContextMenu; -use crate::t; mod controls; mod modal; @@ -132,11 +132,9 @@ fn shortcut_key_name(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> Opt "ArrowRight" => "RIGHT".to_string(), "PageUp" => "PAGEUP".to_string(), "PageDown" => "PAGEDOWN".to_string(), - "Enter" | "Space" | "Escape" | "Delete" | "Backspace" | "Tab" | "Home" - | "End" | "Insert" => name.to_uppercase(), - _ if name.starts_with('F') - && name[1..].chars().all(|ch| ch.is_ascii_digit()) => - { + "Enter" | "Space" | "Escape" | "Delete" | "Backspace" | "Tab" | "Home" | "End" + | "Insert" => name.to_uppercase(), + _ if name.starts_with('F') && name[1..].chars().all(|ch| ch.is_ascii_digit()) => { name } _ => return None, @@ -217,12 +215,7 @@ impl OpenCADStudio { let tab = &self.tabs[i]; let thumbnail_capture_clean = self.thumbnail_capture_clean; let theme_text = self.active_theme.palette().background.base.text; - let viewcube_text_color = [ - theme_text.r, - theme_text.g, - theme_text.b, - theme_text.a, - ]; + let viewcube_text_color = [theme_text.r, theme_text.g, theme_text.b, theme_text.a]; let is_paper = tab.scene.current_layout != "Model"; let committed_render_mode = if is_paper { tab.scene @@ -346,9 +339,8 @@ impl OpenCADStudio { Space::new().width(Fill).height(Fill) }) .into(); - let shaders = pane_grid::PaneGrid::new( - &scene.model_panes, - move |_pane, &idx, _maximized| { + let shaders = + pane_grid::PaneGrid::new(&scene.model_panes, move |_pane, &idx, _maximized| { pane_grid::Content::new( shader(ViewportPane::for_pane( scene, @@ -361,12 +353,11 @@ impl OpenCADStudio { .width(Fill) .height(Fill), ) - }, - ) - .width(Fill) - .height(Fill) - .min_size(scene.model_pane_min_px()) - .spacing(crate::scene::TILE_DIVIDER_PX); + }) + .width(Fill) + .height(Fill) + .min_size(scene.model_pane_min_px()) + .spacing(crate::scene::TILE_DIVIDER_PX); stack![size_probe, shaders].width(Fill).height(Fill).into() }; @@ -375,22 +366,23 @@ impl OpenCADStudio { // above the crosshair overlay so it actually receives mouse events, and // it owns the divider resize. Only built for the Model layout. mark("model_input"); - let model_input_layer: Option> = if is_paper || tab.is_start || self.layout_settling { - None - } else { - let scene = &tab.scene; - Some( - pane_grid::PaneGrid::new(&scene.model_panes, |_pane, &idx, _maximized| { - pane_grid::Content::new(pane_mouse_area(idx)) - }) - .width(Fill) - .height(Fill) - .min_size(scene.model_pane_min_px()) - .spacing(crate::scene::TILE_DIVIDER_PX) - .on_resize(6.0, Message::PaneResized) - .into(), - ) - }; + let model_input_layer: Option> = + if is_paper || tab.is_start || self.layout_settling { + None + } else { + let scene = &tab.scene; + Some( + pane_grid::PaneGrid::new(&scene.model_panes, |_pane, &idx, _maximized| { + pane_grid::Content::new(pane_mouse_area(idx)) + }) + .width(Fill) + .height(Fill) + .min_size(scene.model_pane_min_px()) + .spacing(crate::scene::TILE_DIVIDER_PX) + .on_resize(6.0, Message::PaneResized) + .into(), + ) + }; mark("grid"); let grid_overlay = if self.layout_settling || tab.is_start { @@ -423,8 +415,7 @@ impl OpenCADStudio { let (origin, mut axes): (glam::DVec3, _) = if is_paper { match tab.ucs_from_viewport(handle) { Some(u) => { - let (o, ux, uy, uz) = - super::helpers::UcsXform::from_ucs(&u).axes(); + let (o, ux, uy, uz) = super::helpers::UcsXform::from_ucs(&u).axes(); (o, (ux.as_vec3(), uy.as_vec3(), uz.as_vec3())) } None => ( @@ -501,103 +492,103 @@ bg={bg_ms:.1}ms n={view_count}" .as_ref() .is_some_and(|cmd| cmd.name() == "STRETCH"); - let grips: Vec = - if show_grips && !tab.selected_grips.is_empty() { - let (vw, vh) = sel_ref.vp_size; - // Overlays project through the active tile's camera, so - // they must use the active tile's screen rectangle (with - // its canvas offset) — not the whole canvas — or they - // land in the wrong place in a tiled layout. - // Inside a floating viewport the pane is the viewport's own - // rect + camera; otherwise the active model tile. - let edit_frame = tab.scene.viewport_edit_frame((vw, vh)); - let bounds = match &edit_frame { - Some((_, full)) => *full, - None => tab.scene.active_model_tile_bounds(vw, vh), - }; - let sel_h = tab.selected_handle; - // Mark the Properties panel's current point grip hot. - let current_vertex_grip: Option = tab - .properties - .prop_vertex_indicator_active - .then(|| sel_h) - .flatten() - .and_then(|h| { - let indexed = match tab.scene.document.get_entity(h) { - Some(acadrust::EntityType::LwPolyline(_)) - | Some(acadrust::EntityType::Polyline2D(_)) - | Some(acadrust::EntityType::Polyline3D(_)) - | Some(acadrust::EntityType::Spline(_)) - | Some(acadrust::EntityType::Face3D(_)) - | Some(acadrust::EntityType::PolygonMesh(_)) => true, - _ => false, - }; - indexed.then_some(tab.properties.prop_vertex) - }); - // In-viewport grips are model-space; project them with the - // viewport camera so they sit on the wire the GPU draws. - // Paper entities use the 2-D paper transform; the model tab - // uses the model camera. - let screen_grips = if let Some((cam, _)) = &edit_frame { - grips_to_screen_rte( - &tab.selected_grips, - cam.view_proj_rte(bounds), - cam.eye(), - bounds, - ) - } else if is_paper { - let cam = tab.scene.camera.borrow(); - let aspect = if vh > 0.0 { vw / vh } else { 1.0 }; - let half_h = cam.ortho_size(); - let half_w = half_h * aspect; - let tx = cam.target.x as f32; - let ty = cam.target.y as f32; - drop(cam); - grips_to_screen_paper(&tab.selected_grips, tx, ty, half_w, half_h, bounds) - } else { - let cam = tab.scene.camera.borrow(); - grips_to_screen(&tab.selected_grips, &cam, bounds) - }; - screen_grips - .into_iter() - .enumerate() - .filter(|(_, (_, screen, _, _, _))| { - screen.x.is_finite() - && screen.y.is_finite() - && screen.x >= -bounds.width - && screen.x <= bounds.width * 2.0 - && screen.y >= -bounds.height - && screen.y <= bounds.height * 2.0 - }) - .map(|(index, (grip_id, screen, _is_midpoint, shape, dir))| { - let owner = tab.selected_grip_handles.get(index).copied(); - let is_hot = owner.is_some_and(|handle| { - tab.hot_grips.contains(&(handle, grip_id)) - || tab.active_grip.as_ref().is_some_and(|edit| { - edit.targets.iter().any(|target| { - target.handle == handle && target.grip_id == grip_id - }) - }) - || (Some(handle) == sel_h - && Some(grip_id) == current_vertex_grip) - }); - let is_hovered = owner.is_some_and(|handle| { - self.grip_hover.as_ref().is_some_and(|hover| { - hover.handle == handle && hover.grip_id == grip_id - }) - }); - crate::ui::overlay::GripMarker { - pos: screen, - shape, - is_hot, - is_hovered, - dir, - } - }) - .collect() - } else { - vec![] + let grips: Vec = if show_grips + && !tab.selected_grips.is_empty() + { + let (vw, vh) = sel_ref.vp_size; + // Overlays project through the active tile's camera, so + // they must use the active tile's screen rectangle (with + // its canvas offset) — not the whole canvas — or they + // land in the wrong place in a tiled layout. + // Inside a floating viewport the pane is the viewport's own + // rect + camera; otherwise the active model tile. + let edit_frame = tab.scene.viewport_edit_frame((vw, vh)); + let bounds = match &edit_frame { + Some((_, full)) => *full, + None => tab.scene.active_model_tile_bounds(vw, vh), }; + let sel_h = tab.selected_handle; + // Mark the Properties panel's current point grip hot. + let current_vertex_grip: Option = tab + .properties + .prop_vertex_indicator_active + .then(|| sel_h) + .flatten() + .and_then(|h| { + let indexed = match tab.scene.document.get_entity(h) { + Some(acadrust::EntityType::LwPolyline(_)) + | Some(acadrust::EntityType::Polyline2D(_)) + | Some(acadrust::EntityType::Polyline3D(_)) + | Some(acadrust::EntityType::Spline(_)) + | Some(acadrust::EntityType::Face3D(_)) + | Some(acadrust::EntityType::PolygonMesh(_)) => true, + _ => false, + }; + indexed.then_some(tab.properties.prop_vertex) + }); + // In-viewport grips are model-space; project them with the + // viewport camera so they sit on the wire the GPU draws. + // Paper entities use the 2-D paper transform; the model tab + // uses the model camera. + let screen_grips = if let Some((cam, _)) = &edit_frame { + grips_to_screen_rte( + &tab.selected_grips, + cam.view_proj_rte(bounds), + cam.eye(), + bounds, + ) + } else if is_paper { + let cam = tab.scene.camera.borrow(); + let aspect = if vh > 0.0 { vw / vh } else { 1.0 }; + let half_h = cam.ortho_size(); + let half_w = half_h * aspect; + let tx = cam.target.x as f32; + let ty = cam.target.y as f32; + drop(cam); + grips_to_screen_paper(&tab.selected_grips, tx, ty, half_w, half_h, bounds) + } else { + let cam = tab.scene.camera.borrow(); + grips_to_screen(&tab.selected_grips, &cam, bounds) + }; + screen_grips + .into_iter() + .enumerate() + .filter(|(_, (_, screen, _, _, _))| { + screen.x.is_finite() + && screen.y.is_finite() + && screen.x >= -bounds.width + && screen.x <= bounds.width * 2.0 + && screen.y >= -bounds.height + && screen.y <= bounds.height * 2.0 + }) + .map(|(index, (grip_id, screen, _is_midpoint, shape, dir))| { + let owner = tab.selected_grip_handles.get(index).copied(); + let is_hot = owner.is_some_and(|handle| { + tab.hot_grips.contains(&(handle, grip_id)) + || tab.active_grip.as_ref().is_some_and(|edit| { + edit.targets.iter().any(|target| { + target.handle == handle && target.grip_id == grip_id + }) + }) + || (Some(handle) == sel_h && Some(grip_id) == current_vertex_grip) + }); + let is_hovered = owner.is_some_and(|handle| { + self.grip_hover.as_ref().is_some_and(|hover| { + hover.handle == handle && hover.grip_id == grip_id + }) + }); + crate::ui::overlay::GripMarker { + pos: screen, + shape, + is_hot, + is_hovered, + dir, + } + }) + .collect() + } else { + vec![] + }; let control_polygon = tab.selected_handle.and_then(|handle| { let spline = match tab.scene.document.get_entity(handle) { Some(acadrust::EntityType::Spline(spline)) if spline.cv_frame_visible => spline, @@ -612,15 +603,11 @@ bg={bg_ms:.1}ms n={view_count}" } let points: Vec<_> = grips .iter() - .filter(|grip| { - grip.shape == crate::scene::model::object::GripShape::Circle - }) + .filter(|grip| grip.shape == crate::scene::model::object::GripShape::Circle) .map(|grip| grip.pos) .collect(); - (points.len() >= 2).then_some(( - points, - spline.flags.closed || spline.flags.periodic, - )) + (points.len() >= 2) + .then_some((points, spline.flags.closed || spline.flags.periodic)) }); let grip_clip = if grips.is_empty() { None @@ -650,11 +637,14 @@ bg={bg_ms:.1}ms n={view_count}" vec![] } else if let Some((vp_cam, full)) = tab.scene.viewport_edit_frame((vw, vh)) { let (_, ux, uy, uz) = tab.ucs_xform().axes(); - let origin_screen = self.ucs_icon_at_origin.then(|| { - vp_cam - .project(tab.ucs_origin_world(), full) - .map(|p| iced::Point::new(full.x + p.x, full.y + p.y)) - }).flatten(); + let origin_screen = self + .ucs_icon_at_origin + .then(|| { + vp_cam + .project(tab.ucs_origin_world(), full) + .map(|p| iced::Point::new(full.x + p.x, full.y + p.y)) + }) + .flatten(); vec![crate::ui::overlay::UcsIconParams { view_proj: vp_cam.view_proj_rte(full), bounds: full, @@ -683,11 +673,18 @@ bg={bg_ms:.1}ms n={view_count}" width: (t.rect.width * vw).max(1.0), height: (t.rect.height * vh).max(1.0), }; - let cam = if i == active { live.clone() } else { t.camera.clone() }; - let origin_screen = self.ucs_icon_at_origin.then(|| { - cam.project(origin_w, b) - .map(|p| iced::Point::new(b.x + p.x, b.y + p.y)) - }).flatten(); + let cam = if i == active { + live.clone() + } else { + t.camera.clone() + }; + let origin_screen = self + .ucs_icon_at_origin + .then(|| { + cam.project(origin_w, b) + .map(|p| iced::Point::new(b.x + p.x, b.y + p.y)) + }) + .flatten(); crate::ui::overlay::UcsIconParams { view_proj: cam.view_proj_rte(b), bounds: b, @@ -711,10 +708,8 @@ bg={bg_ms:.1}ms n={view_count}" // viewport). Without the `ob.x/ob.y` offset it silently vanishes. // Shared OTRACK projection basis: the active pane's camera + rect // (canvas offset included), matching the grips / UCS icon above. - let drafting_alignment_active = - self.snapper.alignment_active() - || (tab.active_grip.is_some() - && (self.polar_mode || self.ortho_mode)); + let drafting_alignment_active = self.snapper.alignment_active() + || (tab.active_grip.is_some() && (self.polar_mode || self.ortho_mode)); let otrack_proj: Option<(glam::Mat4, glam::DVec3, iced::Rectangle)> = if drafting_alignment_active { @@ -814,16 +809,17 @@ bg={bg_ms:.1}ms n={view_count}" .hover_highlight .map(|h| tab.scene.is_layer_locked(h)) .unwrap_or(false); - let point_cursor = tab.active_cmd.as_ref().is_some_and(|cmd| { - !cmd.needs_entity_pick() && !cmd.is_selection_gathering() - }); + let point_cursor = tab + .active_cmd + .as_ref() + .is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering()); // Constraint glyphs are displayed in model space only. let constraint_glyphs: Vec<(iced::Point, [f32; 2], String, bool)> = if is_paper { Vec::new() } else { let (vw, vh) = sel_ref.vp_size; - let scope = tab.current_sketch_scope(); - match tab.scene.sketch_constraint_set(scope) { + let scope = tab.current_parametric_scope(); + match tab.scene.parametric_constraint_set(scope) { Some(set) if !set.constraints.is_empty() => { let edit_frame = tab.scene.viewport_edit_frame((vw, vh)); let bounds = match &edit_frame { @@ -839,10 +835,10 @@ bg={bg_ms:.1}ms n={view_count}" set.constraints .iter() .filter(|c| c.enabled) - .filter(|c| tab.scene.is_sketch_constraint_visible(scope, c.id)) + .filter(|c| tab.scene.is_parametric_constraint_visible(scope, c.id)) .filter_map(|c| { let (anchor, outward) = - crate::scene::sketch_constraints::glyph_placement( + crate::scene::parametric_constraints::glyph_placement( &tab.scene.document, c, )?; @@ -862,22 +858,21 @@ bg={bg_ms:.1}ms n={view_count}" eye, bounds, )?; - let direction = (outward_screen - screen) - .normalize_or(glam::Vec2::NEG_Y); - let point = iced::Point::new( - bounds.x + screen.x, - bounds.y + screen.y, - ); + let direction = + (outward_screen - screen).normalize_or(glam::Vec2::NEG_Y); + let point = + iced::Point::new(bounds.x + screen.x, bounds.y + screen.y); let is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id); let label = if self.show_constraint_values { - crate::scene::sketch_constraints::glyph_label(c) + crate::scene::parametric_constraints::glyph_label(c) } else { c.kind.glyph_symbol().to_string() }; - point.x.is_finite().then(|| { - (point, direction.to_array(), label, is_conflicting) - }) + point + .x + .is_finite() + .then(|| (point, direction.to_array(), label, is_conflicting)) }) .collect() } @@ -976,38 +971,38 @@ bg={bg_ms:.1}ms n={view_count}" .as_ref() .map(|c| c.needs_entity_pick() || c.needs_structure_point_pick()) .unwrap_or(false); - let dyn_input_overlay: Option> = - if self.dyn_input - && (tab.active_cmd.is_some() || tab.active_grip.is_some()) - && (!tab.dyn_fields.is_empty() || dyn_picks_object) - { - let w = tab.last_cursor_world; - let base = tab.dyn_anchor.or(self.last_point); - let label_screen = tab - .active_cmd - .as_ref() - .and_then(|command| command.dyn_label_point(w)) - .and_then(|world| { - let (vw, vh) = tab.scene.selection.borrow().vp_size; - let (camera, bounds) = tab - .scene - .viewport_edit_frame((vw, vh)) - .unwrap_or_else(|| { - ( - tab.scene.camera.borrow().clone(), - tab.scene.active_model_tile_bounds(vw, vh), - ) - }); - camera.project(world, bounds).and_then(|point| { - (point.x.is_finite() && point.y.is_finite()).then(|| { - iced::Point::new(bounds.x + point.x, bounds.y + point.y) - }) - }) - }); - // A command may drive a typed scalar by mouse (e.g. a - // perpendicular distance to a picked object); show that live - // value in the box until the user types over it. - let live = tab.active_cmd.as_ref().and_then(|c| c.dyn_live_value(w)).or_else(|| { + let dyn_input_overlay: Option> = if self.dyn_input + && (tab.active_cmd.is_some() || tab.active_grip.is_some()) + && (!tab.dyn_fields.is_empty() || dyn_picks_object) + { + let w = tab.last_cursor_world; + let base = tab.dyn_anchor.or(self.last_point); + let label_screen = tab + .active_cmd + .as_ref() + .and_then(|command| command.dyn_label_point(w)) + .and_then(|world| { + let (vw, vh) = tab.scene.selection.borrow().vp_size; + let (camera, bounds) = + tab.scene.viewport_edit_frame((vw, vh)).unwrap_or_else(|| { + ( + tab.scene.camera.borrow().clone(), + tab.scene.active_model_tile_bounds(vw, vh), + ) + }); + camera.project(world, bounds).and_then(|point| { + (point.x.is_finite() && point.y.is_finite()) + .then(|| iced::Point::new(bounds.x + point.x, bounds.y + point.y)) + }) + }); + // A command may drive a typed scalar by mouse (e.g. a + // perpendicular distance to a picked object); show that live + // value in the box until the user types over it. + let live = tab + .active_cmd + .as_ref() + .and_then(|c| c.dyn_live_value(w)) + .or_else(|| { let grip = tab.active_grip.as_ref()?; let action = match grip.mode { crate::scene::pick::grip::GripEditMode::Lengthen => { @@ -1042,147 +1037,140 @@ bg={bg_ms:.1}ms n={view_count}" w, ) }); - let rectangle_values = tab - .active_grip - .as_ref() - .and_then(|grip| grip.rectangle_frame) - .map(|(opposite, width_axis, height_axis)| { - let delta = w - opposite; - (delta.dot(width_axis).abs(), delta.dot(height_axis).abs()) - }); - let rectangle_label_screens = tab - .active_grip - .as_ref() - .and_then(|grip| grip.rectangle_frame) - .and_then(|(opposite, width_axis, height_axis)| { - let delta = w - opposite; - let width = delta.dot(width_axis); - let height = delta.dot(height_axis); - let width_center = opposite + width_axis * (width * 0.5); - let height_center = opposite - + width_axis * width - + height_axis * (height * 0.5); - let rectangle_center = opposite - + width_axis * (width * 0.5) - + height_axis * (height * 0.5); - let (vw, vh) = tab.scene.selection.borrow().vp_size; - let (camera, bounds) = tab - .scene - .viewport_edit_frame((vw, vh)) - .unwrap_or_else(|| { - ( - tab.scene.camera.borrow().clone(), - tab.scene.active_model_tile_bounds(vw, vh), - ) - }); - let project = |point| { - camera.project(point, bounds).map(|screen| iced::Point::new( - bounds.x + screen.x, - bounds.y + screen.y, - )) - }; - let center = project(rectangle_center)?; - let offset_from_center = |side: iced::Point, pixels: f32| { - let dx = side.x - center.x; - let dy = side.y - center.y; - let length = dx.hypot(dy); - if length > 1.0e-3 { - iced::Point::new( - side.x + dx / length * pixels, - side.y + dy / length * pixels, - ) - } else { - side - } - }; - Some(( - offset_from_center(project(width_center)?, 14.0), - offset_from_center(project(height_center)?, 18.0), - )) - }); - let boxes: Vec = tab - .dyn_fields - .iter() - .enumerate() - .map(|(idx, f)| { - let value = match (&f.buffer, live) { - (Some(b), _) => b.clone(), - (None, _) if rectangle_values.is_some() => { - let (width, height) = rectangle_values.unwrap(); - match f.role { - crate::command::DynRole::Width => format!("{width:.4}"), - crate::command::DynRole::Height => format!("{height:.4}"), - _ => String::new(), - } - } - // An angle step with a command-supplied live value - // (ARC span / direction) shows it in degrees. - (None, Some(lv)) if f.component == DynComponent::Angle => { - format!("{lv:.1}") - } - (None, Some(lv)) - if matches!( - f.component, - DynComponent::Scalar | DynComponent::Distance - ) => - { - format!("{lv:.4}") - } - _ => dyn_component_value( - f, - w, - base, - &tab.ucs_xform(), - self.dyn_user_reshaped, - self.dyn_coord_absolute, - ), - }; - crate::ui::overlay::DynBox { - label: f.role.label().to_string(), - value, - active: idx == tab.dyn_active, - locked: f.locked(), - role: f.role, - center: rectangle_label_screens.and_then(|(width, height)| { - match f.role { - crate::command::DynRole::Width => Some(width), - crate::command::DynRole::Height => Some(height), - _ => None, - } - }), + let rectangle_values = tab + .active_grip + .as_ref() + .and_then(|grip| grip.rectangle_frame) + .map(|(opposite, width_axis, height_axis)| { + let delta = w - opposite; + (delta.dot(width_axis).abs(), delta.dot(height_axis).abs()) + }); + let rectangle_label_screens = tab + .active_grip + .as_ref() + .and_then(|grip| grip.rectangle_frame) + .and_then(|(opposite, width_axis, height_axis)| { + let delta = w - opposite; + let width = delta.dot(width_axis); + let height = delta.dot(height_axis); + let width_center = opposite + width_axis * (width * 0.5); + let height_center = + opposite + width_axis * width + height_axis * (height * 0.5); + let rectangle_center = + opposite + width_axis * (width * 0.5) + height_axis * (height * 0.5); + let (vw, vh) = tab.scene.selection.borrow().vp_size; + let (camera, bounds) = + tab.scene.viewport_edit_frame((vw, vh)).unwrap_or_else(|| { + ( + tab.scene.camera.borrow().clone(), + tab.scene.active_model_tile_bounds(vw, vh), + ) + }); + let project = |point| { + camera.project(point, bounds).map(|screen| { + iced::Point::new(bounds.x + screen.x, bounds.y + screen.y) + }) + }; + let center = project(rectangle_center)?; + let offset_from_center = |side: iced::Point, pixels: f32| { + let dx = side.x - center.x; + let dy = side.y - center.y; + let length = dx.hypot(dy); + if length > 1.0e-3 { + iced::Point::new( + side.x + dx / length * pixels, + side.y + dy / length * pixels, + ) + } else { + side } - }) - .collect(); - let prompt = tab - .active_cmd - .as_ref() - .map(|c| c.prompt()) - .unwrap_or_default(); - - let tracking_hint = match self.otrack_kind { - Some(crate::snap::TrackingKind::Perpendicular) => { - Some(crate::tr!("common", "perpendicular")) + }; + Some(( + offset_from_center(project(width_center)?, 14.0), + offset_from_center(project(height_center)?, 18.0), + )) + }); + let boxes: Vec = tab + .dyn_fields + .iter() + .enumerate() + .map(|(idx, f)| { + let value = match (&f.buffer, live) { + (Some(b), _) => b.clone(), + (None, _) if rectangle_values.is_some() => { + let (width, height) = rectangle_values.unwrap(); + match f.role { + crate::command::DynRole::Width => format!("{width:.4}"), + crate::command::DynRole::Height => format!("{height:.4}"), + _ => String::new(), + } + } + // An angle step with a command-supplied live value + // (ARC span / direction) shows it in degrees. + (None, Some(lv)) if f.component == DynComponent::Angle => { + format!("{lv:.1}") + } + (None, Some(lv)) + if matches!( + f.component, + DynComponent::Scalar | DynComponent::Distance + ) => + { + format!("{lv:.4}") + } + _ => dyn_component_value( + f, + w, + base, + &tab.ucs_xform(), + self.dyn_user_reshaped, + self.dyn_coord_absolute, + ), + }; + crate::ui::overlay::DynBox { + label: f.role.label().to_string(), + value, + active: idx == tab.dyn_active, + locked: f.locked(), + role: f.role, + center: rectangle_label_screens.and_then(|(width, height)| match f.role { + crate::command::DynRole::Width => Some(width), + crate::command::DynRole::Height => Some(height), + _ => None, + }), } - Some(crate::snap::TrackingKind::Extension) => { - Some(crate::tr!("common", "extension")) - } - _ => None, - }; + }) + .collect(); + let prompt = tab + .active_cmd + .as_ref() + .map(|c| c.prompt()) + .unwrap_or_default(); - Some(crate::ui::overlay::dynamic_input_overlay( - tab.last_cursor_screen, - tab.last_point_screen, - tab.dyn_ref_screen, - label_screen, - tab.dyn_guide, - boxes, - prompt, - tracking_hint, - )) - } else { - None + let tracking_hint = match self.otrack_kind { + Some(crate::snap::TrackingKind::Perpendicular) => { + Some(crate::tr!("common", "perpendicular")) + } + Some(crate::snap::TrackingKind::Extension) => { + Some(crate::tr!("common", "extension")) + } + _ => None, }; + Some(crate::ui::overlay::dynamic_input_overlay( + tab.last_cursor_screen, + tab.last_point_screen, + tab.dyn_ref_screen, + label_screen, + tab.dyn_guide, + boxes, + prompt, + tracking_hint, + )) + } else { + None + }; + mark("viewport_stack"); let mut viewport_stack = if tab.is_start || self.layout_settling { // Start tab: only the welcome widget over a flat background. @@ -1246,150 +1234,189 @@ bg={bg_ms:.1}ms n={view_count}" }; if !thumbnail_capture_clean && !self.layout_settling { - // Per-pane input pane_grid goes ABOVE the crosshair overlay so it - // receives mouse events (the overlay's `Hidden` cursor would otherwise - // starve any layer beneath it). The controls bar is pushed on top of it. - if let Some(input) = model_input_layer { - viewport_stack = viewport_stack.push(input); - } + // Per-pane input pane_grid goes ABOVE the crosshair overlay so it + // receives mouse events (the overlay's `Hidden` cursor would otherwise + // starve any layer beneath it). The controls bar is pushed on top of it. + if let Some(input) = model_input_layer { + viewport_stack = viewport_stack.push(input); + } - // Model-space render-mode picker, top-left. Sits ABOVE the - // viewport mouse_area so clicks inside its bounds reach it - // instead of the shader behind it; `opaque` stops them bubbling - // further. Outside the chip the Fill container is transparent so - // viewport drawing / selection is unaffected. In a paper layout - // the active viewport gets its own picker (below) instead. - if !is_paper && !tab.is_start { - let (vw, vh) = tab.scene.selection.borrow().vp_size; - let rect = tab.scene.active_model_tile_bounds(vw, vh); - // Unified control chip: split buttons + render-mode picker + - // grid / grid-snap toggles, for the active Model tile. - let bar = viewport_controls( - tab.render_mode, - self.show_grid, - self.snapper.grid_snap(), - true, - tab.scene.model_tiles.borrow().len(), - self.render_mode_menu_open, - self.render_mode_preview, - ); - // Adaptive: DensitySwap measures the bar's real width every frame - // (reported into `render_bar_w`, which the ViewCube reads to decide - // overlap) and swaps it for an empty spacer only when it no longer - // fits the tile. The fixed-width container bounds that fit decision - // to the tile, not the whole canvas. - let adaptive: Element<'_, Message> = DensitySwap::new(vec![ - iced::widget::opaque(bar), - Space::new() - .width(iced::Length::Fixed(0.0)) - .height(iced::Length::Fixed(0.0)) - .into(), - ]) - .report_width0(self.render_bar_w.clone()) - .report_width0(tab.scene.model_pane_min_reporter()) - .into(); - // Pin the bar to the active model tile's top-left corner so it - // follows the active panel in a tiled layout. - let bar_layer = iced::widget::pin( - container(adaptive).width(iced::Length::Fixed(rect.width.max(1.0))), - ) - .position(iced::Point::new(rect.x.max(0.0), rect.y.max(0.0))); - viewport_stack = viewport_stack.push(bar_layer); - } + // Model-space render-mode picker, top-left. Sits ABOVE the + // viewport mouse_area so clicks inside its bounds reach it + // instead of the shader behind it; `opaque` stops them bubbling + // further. Outside the chip the Fill container is transparent so + // viewport drawing / selection is unaffected. In a paper layout + // the active viewport gets its own picker (below) instead. + if !is_paper && !tab.is_start { + let (vw, vh) = tab.scene.selection.borrow().vp_size; + let rect = tab.scene.active_model_tile_bounds(vw, vh); + // Unified control chip: split buttons + render-mode picker + + // grid / grid-snap toggles, for the active Model tile. + let bar = viewport_controls( + tab.render_mode, + self.show_grid, + self.snapper.grid_snap(), + true, + tab.scene.model_tiles.borrow().len(), + self.render_mode_menu_open, + self.render_mode_preview, + ); + // Adaptive: DensitySwap measures the bar's real width every frame + // (reported into `render_bar_w`, which the ViewCube reads to decide + // overlap) and swaps it for an empty spacer only when it no longer + // fits the tile. The fixed-width container bounds that fit decision + // to the tile, not the whole canvas. + let adaptive: Element<'_, Message> = DensitySwap::new(vec![ + iced::widget::opaque(bar), + Space::new() + .width(iced::Length::Fixed(0.0)) + .height(iced::Length::Fixed(0.0)) + .into(), + ]) + .report_width0(self.render_bar_w.clone()) + .report_width0(tab.scene.model_pane_min_reporter()) + .into(); + // Pin the bar to the active model tile's top-left corner so it + // follows the active panel in a tiled layout. + let bar_layer = iced::widget::pin( + container(adaptive).width(iced::Length::Fixed(rect.width.max(1.0))), + ) + .position(iced::Point::new(rect.x.max(0.0), rect.y.max(0.0))); + viewport_stack = viewport_stack.push(bar_layer); + } - // Active paper-space viewport overlays: a render-mode picker in - // its top-left corner and a ViewCube hit area in its top-right, - // both layered ABOVE the viewport mouse_area so they receive - // clicks (the shader viewport sits below it). Positioned with - // leading Spaces sized to the viewport's screen rectangle. - mark("active_vp_rect"); - let active_vp_rect: Option<(acadrust::Handle, iced::Rectangle)> = - if is_paper && !tab.is_start { - tab.scene.active_viewport.and_then(|h| { - let (cw, ch) = tab.scene.selection.borrow().vp_size; - tab.scene - .viewport_screen_rect(h, (cw, ch)) - .map(|rect| (h, rect)) - }) - } else { - None - }; - if let Some((active_vp, rect)) = active_vp_rect { - // Clip the outline to the visible canvas. Clamping only the origin - // (max(0.0)) while keeping the full width/height shifted the whole - // outline inward when the viewport ran off the top/left edge, so - // its drawn border no longer matched the real viewport — clicks - // that looked outside landed in (and activated) another viewport. - let (cw, ch) = tab.scene.selection.borrow().vp_size; - let x = rect.x.max(0.0); - let y = rect.y.max(0.0); - let vw = ((rect.x + rect.width).min(cw) - x).max(1.0); - let vh = ((rect.y + rect.height).min(ch) - y).max(1.0); - // Highlight the active viewport with a 2-px border so its - // boundary is always visible over the GPU shader. - const VP_BORDER: Color = Color { - r: 0.18, - g: 0.52, - b: 0.95, - a: 1.0, - }; - let border_frame = container( - Space::new() - .width(iced::Length::Fixed(vw)) - .height(iced::Length::Fixed(vh)), - ) - .style(move |_: &Theme| container::Style { - border: iced::Border { - color: VP_BORDER, - width: 2.0, - radius: 0.0.into(), - }, - ..Default::default() - }); - let border_layer = - iced::widget::pin(border_frame).position(iced::Point::new(x, y)); - viewport_stack = viewport_stack.push(border_layer); + // Active paper-space viewport overlays: a render-mode picker in + // its top-left corner and a ViewCube hit area in its top-right, + // both layered ABOVE the viewport mouse_area so they receive + // clicks (the shader viewport sits below it). Positioned with + // leading Spaces sized to the viewport's screen rectangle. + mark("active_vp_rect"); + let active_vp_rect: Option<(acadrust::Handle, iced::Rectangle)> = + if is_paper && !tab.is_start { + tab.scene.active_viewport.and_then(|h| { + let (cw, ch) = tab.scene.selection.borrow().vp_size; + tab.scene + .viewport_screen_rect(h, (cw, ch)) + .map(|rect| (h, rect)) + }) + } else { + None + }; + if let Some((active_vp, rect)) = active_vp_rect { + // Clip the outline to the visible canvas. Clamping only the origin + // (max(0.0)) while keeping the full width/height shifted the whole + // outline inward when the viewport ran off the top/left edge, so + // its drawn border no longer matched the real viewport — clicks + // that looked outside landed in (and activated) another viewport. + let (cw, ch) = tab.scene.selection.borrow().vp_size; + let x = rect.x.max(0.0); + let y = rect.y.max(0.0); + let vw = ((rect.x + rect.width).min(cw) - x).max(1.0); + let vh = ((rect.y + rect.height).min(ch) - y).max(1.0); + // Highlight the active viewport with a 2-px border so its + // boundary is always visible over the GPU shader. + const VP_BORDER: Color = Color { + r: 0.18, + g: 0.52, + b: 0.95, + a: 1.0, + }; + let border_frame = container( + Space::new() + .width(iced::Length::Fixed(vw)) + .height(iced::Length::Fixed(vh)), + ) + .style(move |_: &Theme| container::Style { + border: iced::Border { + color: VP_BORDER, + width: 2.0, + radius: 0.0.into(), + }, + ..Default::default() + }); + let border_layer = iced::widget::pin(border_frame).position(iced::Point::new(x, y)); + viewport_stack = viewport_stack.push(border_layer); - let vp_mode = tab - .scene - .active_viewport_render_mode() - .unwrap_or(acadrust::entities::ViewportRenderMode::Wireframe2D); - // Adaptive (same as model): the picker measures its real width into - // `render_bar_w` and swaps to an empty spacer only when the viewport - // can't hold it; the ViewCube reads that width to decide overlap. - let bar = viewport_controls( - vp_mode, - self.show_grid, - self.snapper.grid_snap(), - false, - 0, - self.render_mode_menu_open, - self.render_mode_preview, - ); - let adaptive: Element<'_, Message> = DensitySwap::new(vec![ - iced::widget::opaque(bar), - Space::new() - .width(iced::Length::Fixed(0.0)) - .height(iced::Length::Fixed(0.0)) - .into(), - ]) - .report_width0(self.render_bar_w.clone()) - .into(); - let picker_layer = iced::widget::pin( - container(adaptive).width(iced::Length::Fixed(rect.width.max(1.0))), - ) - .position(iced::Point::new(x + 4.0, y + 4.0)); - viewport_stack = viewport_stack.push(picker_layer); + let vp_mode = tab + .scene + .active_viewport_render_mode() + .unwrap_or(acadrust::entities::ViewportRenderMode::Wireframe2D); + // Adaptive (same as model): the picker measures its real width into + // `render_bar_w` and swaps to an empty spacer only when the viewport + // can't hold it; the ViewCube reads that width to decide overlap. + let bar = viewport_controls( + vp_mode, + self.show_grid, + self.snapper.grid_snap(), + false, + 0, + self.render_mode_menu_open, + self.render_mode_preview, + ); + let adaptive: Element<'_, Message> = DensitySwap::new(vec![ + iced::widget::opaque(bar), + Space::new() + .width(iced::Length::Fixed(0.0)) + .height(iced::Length::Fixed(0.0)) + .into(), + ]) + .report_width0(self.render_bar_w.clone()) + .into(); + let picker_layer = iced::widget::pin( + container(adaptive).width(iced::Length::Fixed(rect.width.max(1.0))), + ) + .position(iced::Point::new(x + 4.0, y + 4.0)); + viewport_stack = viewport_stack.push(picker_layer); - // Hide the ViewCube first — before the render bar — when they collide. - if viewcube_visible { + // Hide the ViewCube first — before the render bar — when they collide. + if viewcube_visible { + let cube_x = (rect.x + rect.width - VIEWCUBE_HIT_SIZE - VIEWCUBE_PAD).max(0.0); + let cube_y = (rect.y + VIEWCUBE_PAD).max(0.0); + + let controls = iced::widget::pin(viewcube_nav_controls(Some(active_vp))) + .position(iced::Point::new(cube_x, cube_y)); + viewport_stack = viewport_stack.push(controls); + + let ucs_current = tab + .active_ucs + .as_ref() + .map(|u| u.name.clone()) + .unwrap_or_default(); + let ucs_names: Vec = tab + .scene + .document + .ucss + .iter() + .map(|u| u.name.clone()) + .filter(|n| !n.is_empty()) + .collect(); + let picker = iced::widget::pin(iced::widget::opaque(viewcube_ucs_picker( + ucs_current, + ucs_names, + ))) + .position(iced::Point::new( + cube_x + VIEWCUBE_HIT_SIZE * 0.5 - UCS_PICKER_W * 0.5, + cube_y + VIEWCUBE_HIT_SIZE + 10.0, + )); + viewport_stack = viewport_stack.push(picker); + } + } + + if viewcube_visible && !is_paper { + // Place the ViewCube hit area in the active model tile's top-right + // corner so it tracks the active panel in a tiled layout. The hit + // test in update.rs already maps clicks through the active tile. + let (vw, vh) = tab.scene.selection.borrow().vp_size; + let rect = tab.scene.active_model_tile_bounds(vw, vh); let cube_x = (rect.x + rect.width - VIEWCUBE_HIT_SIZE - VIEWCUBE_PAD).max(0.0); let cube_y = (rect.y + VIEWCUBE_PAD).max(0.0); - let controls = iced::widget::pin(viewcube_nav_controls(Some(active_vp))) + // Cube hit area + nav controls (home / roll / nudge) as one layer. + let controls = iced::widget::pin(viewcube_nav_controls(None)) .position(iced::Point::new(cube_x, cube_y)); viewport_stack = viewport_stack.push(controls); + // WCS / named-UCS selector under the cube. let ucs_current = tab .active_ucs .as_ref() @@ -1408,441 +1435,401 @@ bg={bg_ms:.1}ms n={view_count}" ucs_names, ))) .position(iced::Point::new( - cube_x + VIEWCUBE_HIT_SIZE * 0.5 - UCS_PICKER_W * 0.5, - cube_y + VIEWCUBE_HIT_SIZE + 10.0, - )); - viewport_stack = viewport_stack.push(picker); - } - } - - if viewcube_visible && !is_paper { - // Place the ViewCube hit area in the active model tile's top-right - // corner so it tracks the active panel in a tiled layout. The hit - // test in update.rs already maps clicks through the active tile. - let (vw, vh) = tab.scene.selection.borrow().vp_size; - let rect = tab.scene.active_model_tile_bounds(vw, vh); - let cube_x = (rect.x + rect.width - VIEWCUBE_HIT_SIZE - VIEWCUBE_PAD).max(0.0); - let cube_y = (rect.y + VIEWCUBE_PAD).max(0.0); - - // Cube hit area + nav controls (home / roll / nudge) as one layer. - let controls = iced::widget::pin(viewcube_nav_controls(None)) - .position(iced::Point::new(cube_x, cube_y)); - viewport_stack = viewport_stack.push(controls); - - // WCS / named-UCS selector under the cube. - let ucs_current = tab - .active_ucs - .as_ref() - .map(|u| u.name.clone()) - .unwrap_or_default(); - let ucs_names: Vec = tab - .scene - .document - .ucss - .iter() - .map(|u| u.name.clone()) - .filter(|n| !n.is_empty()) - .collect(); - let picker = iced::widget::pin(iced::widget::opaque(viewcube_ucs_picker( - ucs_current, - ucs_names, - ))) - .position(iced::Point::new( cube_x + VIEWCUBE_HIT_SIZE * 0.5 - UCS_PICKER_W * 0.5, cube_y + VIEWCUBE_HIT_SIZE + 10.0, )); - viewport_stack = viewport_stack.push(picker); - } - - if let Some(dyn_ol) = dyn_input_overlay { - if !tab.is_start { - viewport_stack = viewport_stack.push(dyn_ol); + viewport_stack = viewport_stack.push(picker); } - } - if let Some(popup) = self.grip_popup.as_ref() { - if !tab.is_start { - let labels: Vec = popup - .items - .iter() - .map(|item| { - let (mark, raw) = item - .label - .strip_prefix("✓ ") - .map_or(("", item.label), |raw| ("✓ ", raw)); - format!("{mark}{}", crate::i18n::translate(raw)) - }) - .collect(); - let max_len = labels - .iter() - .map(|label| label.chars().count()) - .max() - .unwrap_or(8) as f32; - let row_w = max_len * 7.0 + 24.0; - let mut col = column![].spacing(0).width(iced::Length::Fixed(row_w)); - for (idx, label) in labels.into_iter().enumerate() { - let is_sel = idx == popup.selected; - let btn = button(text(label).size(12)) - .on_press(Message::GripMenuPick(idx)) - .padding([3, 10]) - .width(Fill) - .style(move |theme: &Theme, status| { - let palette = theme.palette(); - let pair = match (is_sel, status) { - (true, _) => Some(palette.primary.strong), - (_, iced::widget::button::Status::Hovered) => { - Some(palette.background.strong) - } - _ => None, - }; - iced::widget::button::Style { - background: pair.map(|p| Background::Color(p.color)), - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 0.0.into(), - }, - text_color: pair - .map(|p| p.text) - .unwrap_or(palette.background.base.text), - ..Default::default() - } - }); - col = col.push(btn); + if let Some(dyn_ol) = dyn_input_overlay { + if !tab.is_start { + viewport_stack = viewport_stack.push(dyn_ol); } - let menu_panel = container(col) - .padding(2) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - } - }); - // Offset the menu by 12 px so the cursor doesn't land on - // the first item immediately, matching the right-click - // context menu's "panel below the click point" feel. - let anchor = iced::Point::new(popup.anchor.x + 12.0, popup.anchor.y + 12.0); - viewport_stack = - viewport_stack.push(position_canvas_overlay(anchor, menu_panel.into())); } - } - // Dynamic-block visibility-state dropdown. - if let Some(popup) = self.visibility_popup.as_ref() { - if !tab.is_start { - let max_len = popup - .items - .iter() - .map(|s| s.chars().count()) - .max() - .unwrap_or(4) as f32; - // +2 chars for the leading "✓ " / " " marker column. - let row_w = (max_len + 2.0) * 7.0 + 24.0; - let mut col = column![].spacing(0).width(iced::Length::Fixed(row_w)); - for (idx, name) in popup.items.iter().enumerate() { - let is_cur = popup.current == Some(idx); - let mark: Element<'_, Message> = if is_cur { - crate::ui::icons::themed_check_cell(true) - } else { - Space::new().width(11).into() - }; - let btn = button( - row![ - container(mark).width(16), - text(name).size(12), - ] - .spacing(2) - .align_y(iced::Center), - ) - .on_press(Message::VisibilityPick(idx)) - .padding([3, 10]) - .width(Fill) - .style(move |theme: &Theme, status| { - let palette = theme.palette(); - iced::widget::button::Style { - background: matches!( - status, - iced::widget::button::Status::Hovered - ) - .then_some(Background::Color(palette.primary.weak.color)), - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 0.0.into(), - }, - text_color: palette.background.base.text, - ..Default::default() - } - }); - col = col.push(btn); - } - let panel = container(iced::widget::scrollable(col).height(iced::Length::Shrink)) - .height(iced::Length::Fit.max(360.0)) - .padding(2) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - } - }); - let anchor = iced::Point::new(popup.anchor.x + 12.0, popup.anchor.y + 12.0); - viewport_stack = - viewport_stack.push(position_canvas_overlay(anchor, panel.into())); - } - } - - // Paper-space context actions: a right-edge vertical toolbar - // (viewport / page setup / plot) instead of a contextual ribbon tab. - if is_paper && !tab.is_start { - if let Some(tb) = crate::ui::side_toolbar::view( - &crate::modules::layout::paper_space_tools(), - ) { - viewport_stack = viewport_stack.push(tb); - } - } - - // In-place block edit (REFEDIT): right-edge toolbar with Save / Discard - // so the edit can be finished by clicking. (#136) - if tab.refedit_session.is_some() && !tab.is_start { - if let Some(tb) = crate::ui::side_toolbar::view( - &crate::modules::draw::modify::refedit::refedit_tools(), - ) { - viewport_stack = viewport_stack.push(tb); - } - } - - // BEDIT block editor: right-edge Save Block / Discard toolbar (#261). - if tab.active_block_edit.is_some() && !tab.is_start { - if let Some(tb) = crate::ui::side_toolbar::view( - &crate::modules::draw::modify::block_edit::block_edit_tools(), - ) { - viewport_stack = viewport_stack.push(tb); - } - } - - // Reserve the overlaid command line when placing cursor-anchored panels. - mark("chrome"); - let command_line_inset = if self.command_line.history_open { - self.command_line.history_height.clamp( - crate::ui::command_line::HISTORY_HEIGHT_MIN, - crate::ui::command_line::history_max_height(self.win_size.1), - ) + 72.0 - } else { - 34.0 - }; - - // Quick Properties: stay near the selection cursor, flipping around - // it as needed to remain inside the visible drawing area. - if self.quick_properties && !tab.is_start { - if let Some(panel) = tab.properties.quick_view() { - viewport_stack = viewport_stack.push(position_canvas_overlay_near_cursor( - self.quick_properties_anchor, - command_line_inset, - panel, - )); - } - } - - // Shared performance panel: terminal PERF lines plus the current - // tessellation summary. Copy / Clear mirror the command-history panel. - if self.perf_hud { - let s = &tab.scene; - let perf_w = if render_bar_w.is_finite() && render_bar_w > 1.0 { - render_bar_w - } else { - 320.0 - }; - let summary = format!( - "tess {:.1} ms · {} wires · epoch {}", - s.last_tess_ms.get(), - s.last_tess_wires.get(), - s.geometry_epoch, - ); - let trace = crate::perf::snapshot_tail_text(80); - let trace = if trace.is_empty() { - t!("No samples yet").into_owned() - } else { - trace - }; - let perf_button_style = |theme: &Theme, status: button::Status| { - let palette = theme.palette(); - let pair = if matches!(status, button::Status::Hovered) { - palette.background.strong - } else { - palette.background.weak - }; - button::Style { - background: Some(Background::Color(pair.color)), - text_color: pair.text, - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - } - }; - let copy_btn = button( - row![ - crate::ui::icons::themed_primary(crate::ui::icons::COPY, 11.0), - text(t!("Copy")).size(11), - ] - .spacing(4) - .align_y(iced::Center), - ) - .on_press(Message::PerfCopy) - .style(perf_button_style) - .padding([2, 6]); - let clear_btn = button( - row![ - crate::ui::icons::themed_danger(crate::ui::icons::TRASH, 11.0), - text(t!("Clear")).size(11), - ] - .spacing(4) - .align_y(iced::Center), - ) - .on_press(Message::PerfClear) - .style(perf_button_style) - .padding([2, 6]); - let header = row![ - text(t!("PERF")).size(12).style(|theme: &Theme| iced::widget::text::Style { - color: Some(theme.palette().success.base.color), - }), - Space::new().width(iced::Length::Fill), - copy_btn, - clear_btn, - ] - .spacing(6) - .align_y(iced::Center); - let log = scrollable(text(trace).size(11)) - .height(iced::Length::Fixed(220.0)) - .width(iced::Length::Fill); - let panel = container( - column![ - header, - text(summary).size(11).style(|theme: &Theme| iced::widget::text::Style { - color: Some(theme.palette().success.base.color), - }), - log, - ] - .spacing(5), - ) - .width(iced::Length::Fixed(perf_w)) - .padding(6) - .style(|_: &Theme| container::Style { - background: None, - border: Border::default(), - ..Default::default() - }); - viewport_stack = viewport_stack.push(position_canvas_overlay( - iced::Point::new(12.0, 40.0), - panel.into(), - )); - } - - // Selection-cycling list box: pick among overlapping objects. - if let Some((pt, cands)) = &self.cycle_candidates { - if !tab.is_start { - let items: Vec = cands - .iter() - .filter_map(|&h| { - tab.scene.document.get_entity(h).map(|e| { - let style = crate::scene::view::render::render_style_for_viewport( - &tab.scene.document, - e, - None, - ); - crate::ui::popup::cycle_popup::CycleCandidate { - handle: h, - type_name: crate::entities::traits::entity_type_name(e) - .to_string(), - layer: e.common().layer.clone(), - color: style.0, - } + if let Some(popup) = self.grip_popup.as_ref() { + if !tab.is_start { + let labels: Vec = popup + .items + .iter() + .map(|item| { + let (mark, raw) = item + .label + .strip_prefix("✓ ") + .map_or(("", item.label), |raw| ("✓ ", raw)); + format!("{mark}{}", crate::i18n::translate(raw)) }) - }) - .collect(); - if !items.is_empty() { - viewport_stack = viewport_stack - .push(crate::ui::popup::cycle_popup::cycle_popup_overlay(*pt, items)); + .collect(); + let max_len = labels + .iter() + .map(|label| label.chars().count()) + .max() + .unwrap_or(8) as f32; + let row_w = max_len * 7.0 + 24.0; + let mut col = column![].spacing(0).width(iced::Length::Fixed(row_w)); + for (idx, label) in labels.into_iter().enumerate() { + let is_sel = idx == popup.selected; + let btn = button(text(label).size(12)) + .on_press(Message::GripMenuPick(idx)) + .padding([3, 10]) + .width(Fill) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let pair = match (is_sel, status) { + (true, _) => Some(palette.primary.strong), + (_, iced::widget::button::Status::Hovered) => { + Some(palette.background.strong) + } + _ => None, + }; + iced::widget::button::Style { + background: pair.map(|p| Background::Color(p.color)), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 0.0.into(), + }, + text_color: pair + .map(|p| p.text) + .unwrap_or(palette.background.base.text), + ..Default::default() + } + }); + col = col.push(btn); + } + let menu_panel = container(col).padding(2).style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + } + }); + // Offset the menu by 12 px so the cursor doesn't land on + // the first item immediately, matching the right-click + // context menu's "panel below the click point" feel. + let anchor = iced::Point::new(popup.anchor.x + 12.0, popup.anchor.y + 12.0); + viewport_stack = + viewport_stack.push(position_canvas_overlay(anchor, menu_panel.into())); } } - } - // Right-click context menu. Lives inside the viewport stack so - // the cursor position (canvas-relative) anchors the menu under - // the cursor instead of drifting into window-relative space. - if !tab.is_start { - let (ctx_pos, draworder_open) = { - let sel = tab.scene.selection.borrow(); - (sel.context_menu, sel.draworder_submenu) + // Dynamic-block visibility-state dropdown. + if let Some(popup) = self.visibility_popup.as_ref() { + if !tab.is_start { + let max_len = popup + .items + .iter() + .map(|s| s.chars().count()) + .max() + .unwrap_or(4) as f32; + // +2 chars for the leading "✓ " / " " marker column. + let row_w = (max_len + 2.0) * 7.0 + 24.0; + let mut col = column![].spacing(0).width(iced::Length::Fixed(row_w)); + for (idx, name) in popup.items.iter().enumerate() { + let is_cur = popup.current == Some(idx); + let mark: Element<'_, Message> = if is_cur { + crate::ui::icons::themed_check_cell(true) + } else { + Space::new().width(11).into() + }; + let btn = button( + row![container(mark).width(16), text(name).size(12),] + .spacing(2) + .align_y(iced::Center), + ) + .on_press(Message::VisibilityPick(idx)) + .padding([3, 10]) + .width(Fill) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + iced::widget::button::Style { + background: matches!(status, iced::widget::button::Status::Hovered) + .then_some(Background::Color(palette.primary.weak.color)), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 0.0.into(), + }, + text_color: palette.background.base.text, + ..Default::default() + } + }); + col = col.push(btn); + } + let panel = + container(iced::widget::scrollable(col).height(iced::Length::Shrink)) + .height(iced::Length::Fit.max(360.0)) + .padding(2) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color( + palette.background.weak.color, + )), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + } + }); + let anchor = iced::Point::new(popup.anchor.x + 12.0, popup.anchor.y + 12.0); + viewport_stack = + viewport_stack.push(position_canvas_overlay(anchor, panel.into())); + } + } + + // Paper-space context actions: a right-edge vertical toolbar + // (viewport / page setup / plot) instead of a contextual ribbon tab. + if is_paper && !tab.is_start { + if let Some(tb) = + crate::ui::side_toolbar::view(&crate::modules::layout::paper_space_tools()) + { + viewport_stack = viewport_stack.push(tb); + } + } + + // In-place block edit (REFEDIT): right-edge toolbar with Save / Discard + // so the edit can be finished by clicking. (#136) + if tab.refedit_session.is_some() && !tab.is_start { + if let Some(tb) = crate::ui::side_toolbar::view( + &crate::modules::draw::modify::refedit::refedit_tools(), + ) { + viewport_stack = viewport_stack.push(tb); + } + } + + // BEDIT block editor: right-edge Save Block / Discard toolbar (#261). + if tab.active_block_edit.is_some() && !tab.is_start { + if let Some(tb) = crate::ui::side_toolbar::view( + &crate::modules::draw::modify::block_edit::block_edit_tools(), + ) { + viewport_stack = viewport_stack.push(tb); + } + } + + // Reserve the overlaid command line when placing cursor-anchored panels. + mark("chrome"); + let command_line_inset = if self.command_line.history_open { + self.command_line.history_height.clamp( + crate::ui::command_line::HISTORY_HEIGHT_MIN, + crate::ui::command_line::history_max_height(self.win_size.1), + ) + 72.0 + } else { + 34.0 }; - if let Some(p) = ctx_pos { - let has_cmd = tab.active_cmd.is_some(); - // Same guard as typed MTP/M2P and SnapOverrideMtp. - let has_point_step = tab.active_cmd.as_ref().is_some_and(|c| { - (!c.input_kind().wants_text() || c.point_step_accepts_keywords()) - && !c.needs_entity_pick() - }); - let has_selection = !tab.scene.selected.is_empty(); - let isolation_active = tab.scene.is_isolation_active(); - let last_cmds: Vec = self - .command_line - .recent_commands - .iter() - .rev() - .take(3) - .cloned() - .collect(); - viewport_stack = viewport_stack.push(viewport_context_menu_overlay( - p, - command_line_inset, - has_cmd, - has_selection, - isolation_active, - last_cmds, - draworder_open, - has_point_step, - )); - } - } - // In-place MText editor (toolbar + text area), anchored at the - // insertion-point click. - if !tab.is_start { - let canvas = tab.scene.selection.borrow().vp_size; - if let Some(ed) = &self.mtext_editor { - let styles: Vec = tab - .scene - .document - .text_styles - .iter() - .map(|s| s.name.clone()) - .collect(); - viewport_stack = viewport_stack.push(mtext_editor_overlay( - ed, - styles, - self.modal_offset, - self.modal_resize, - self.modal_content_size, + // Quick Properties: stay near the selection cursor, flipping around + // it as needed to remain inside the visible drawing area. + if self.quick_properties && !tab.is_start { + if let Some(panel) = tab.properties.quick_view() { + viewport_stack = viewport_stack.push(position_canvas_overlay_near_cursor( + self.quick_properties_anchor, + command_line_inset, + panel, + )); + } + } + + // Shared performance panel: terminal PERF lines plus the current + // tessellation summary. Copy / Clear mirror the command-history panel. + if self.perf_hud { + let s = &tab.scene; + let perf_w = if render_bar_w.is_finite() && render_bar_w > 1.0 { + render_bar_w + } else { + 320.0 + }; + let summary = format!( + "tess {:.1} ms · {} wires · epoch {}", + s.last_tess_ms.get(), + s.last_tess_wires.get(), + s.geometry_epoch, + ); + let trace = crate::perf::snapshot_tail_text(80); + let trace = if trace.is_empty() { + t!("No samples yet").into_owned() + } else { + trace + }; + let perf_button_style = |theme: &Theme, status: button::Status| { + let palette = theme.palette(); + let pair = if matches!(status, button::Status::Hovered) { + palette.background.strong + } else { + palette.background.weak + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + } + }; + let copy_btn = button( + row![ + crate::ui::icons::themed_primary(crate::ui::icons::COPY, 11.0), + text(t!("Copy")).size(11), + ] + .spacing(4) + .align_y(iced::Center), + ) + .on_press(Message::PerfCopy) + .style(perf_button_style) + .padding([2, 6]); + let clear_btn = button( + row![ + crate::ui::icons::themed_danger(crate::ui::icons::TRASH, 11.0), + text(t!("Clear")).size(11), + ] + .spacing(4) + .align_y(iced::Center), + ) + .on_press(Message::PerfClear) + .style(perf_button_style) + .padding([2, 6]); + let header = row![ + text(t!("PERF")) + .size(12) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.palette().success.base.color), + }), + Space::new().width(iced::Length::Fill), + copy_btn, + clear_btn, + ] + .spacing(6) + .align_y(iced::Center); + let log = scrollable(text(trace).size(11)) + .height(iced::Length::Fixed(220.0)) + .width(iced::Length::Fill); + let panel = container( + column![ + header, + text(summary) + .size(11) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.palette().success.base.color), + }), + log, + ] + .spacing(5), + ) + .width(iced::Length::Fixed(perf_w)) + .padding(6) + .style(|_: &Theme| container::Style { + background: None, + border: Border::default(), + ..Default::default() + }); + viewport_stack = viewport_stack.push(position_canvas_overlay( + iced::Point::new(12.0, 40.0), + panel.into(), )); } - if let Some(ed) = &self.text_inline { - viewport_stack = viewport_stack.push(text_inline_overlay(ed, canvas)); + + // Selection-cycling list box: pick among overlapping objects. + if let Some((pt, cands)) = &self.cycle_candidates { + if !tab.is_start { + let items: Vec = cands + .iter() + .filter_map(|&h| { + tab.scene.document.get_entity(h).map(|e| { + let style = crate::scene::view::render::render_style_for_viewport( + &tab.scene.document, + e, + None, + ); + crate::ui::popup::cycle_popup::CycleCandidate { + handle: h, + type_name: crate::entities::traits::entity_type_name(e) + .to_string(), + layer: e.common().layer.clone(), + color: style.0, + } + }) + }) + .collect(); + if !items.is_empty() { + viewport_stack = viewport_stack.push( + crate::ui::popup::cycle_popup::cycle_popup_overlay(*pt, items), + ); + } + } + } + + // Right-click context menu. Lives inside the viewport stack so + // the cursor position (canvas-relative) anchors the menu under + // the cursor instead of drifting into window-relative space. + if !tab.is_start { + let (ctx_pos, draworder_open) = { + let sel = tab.scene.selection.borrow(); + (sel.context_menu, sel.draworder_submenu) + }; + if let Some(p) = ctx_pos { + let has_cmd = tab.active_cmd.is_some(); + // Same guard as typed MTP/M2P and SnapOverrideMtp. + let has_point_step = tab.active_cmd.as_ref().is_some_and(|c| { + (!c.input_kind().wants_text() || c.point_step_accepts_keywords()) + && !c.needs_entity_pick() + }); + let has_selection = !tab.scene.selected.is_empty(); + let isolation_active = tab.scene.is_isolation_active(); + let last_cmds: Vec = self + .command_line + .recent_commands + .iter() + .rev() + .take(3) + .cloned() + .collect(); + viewport_stack = viewport_stack.push(viewport_context_menu_overlay( + p, + command_line_inset, + has_cmd, + has_selection, + isolation_active, + last_cmds, + draworder_open, + has_point_step, + )); + } + } + + // In-place MText editor (toolbar + text area), anchored at the + // insertion-point click. + if !tab.is_start { + let canvas = tab.scene.selection.borrow().vp_size; + if let Some(ed) = &self.mtext_editor { + let styles: Vec = tab + .scene + .document + .text_styles + .iter() + .map(|s| s.name.clone()) + .collect(); + viewport_stack = viewport_stack.push(mtext_editor_overlay( + ed, + styles, + self.modal_offset, + self.modal_resize, + self.modal_content_size, + )); + } + if let Some(ed) = &self.text_inline { + viewport_stack = viewport_stack.push(text_inline_overlay(ed, canvas)); + } } - } } // Docked side panels (Properties, block palette, future palettes) live @@ -1859,20 +1846,19 @@ bg={bg_ms:.1}ms n={view_count}" crate::ui::dock::PanelId::BlockPalette => self.show_block_palette, } }; - let edge_stack = - |side: crate::app::config::DockSide| -> Option> { - let ids: Vec = match side { - crate::app::config::DockSide::Left => self.dock.left.clone(), - crate::app::config::DockSide::Right => self.dock.right.clone(), - } - .into_iter() - .filter(|id| visible_panel(*id)) - .collect(); - if ids.is_empty() { - return None; - } - Some(self.build_edge_stack(side, &ids, tab)) - }; + let edge_stack = |side: crate::app::config::DockSide| -> Option> { + let ids: Vec = match side { + crate::app::config::DockSide::Left => self.dock.left.clone(), + crate::app::config::DockSide::Right => self.dock.right.clone(), + } + .into_iter() + .filter(|id| visible_panel(*id)) + .collect(); + if ids.is_empty() { + return None; + } + Some(self.build_edge_stack(side, &ids, tab)) + }; let left_edge = edge_stack(crate::app::config::DockSide::Left); let right_edge = edge_stack(crate::app::config::DockSide::Right); @@ -1900,24 +1886,19 @@ bg={bg_ms:.1}ms n={view_count}" | crate::scene::pick::grip::GripEditMode::MoveParallel ) }); - let dyn_capturing = - (self.dyn_input - && (tab.active_cmd.is_some() || tab.active_grip.is_some()) - && !tab.dyn_fields.is_empty() - && !interactive_value_grip) - || self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) - || self.text_inline.is_some(); + let dyn_capturing = (self.dyn_input + && (tab.active_cmd.is_some() || tab.active_grip.is_some()) + && !tab.dyn_fields.is_empty() + && !interactive_value_grip) + || self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) + || self.text_inline.is_some(); // The workspace row is: left edge stack, viewport, right edge stack. let mut parts: Vec> = Vec::new(); if let Some(e) = left_edge { parts.push(e); } parts.push( - crate::ui::wrap_bar::PosReport::new( - VIEWPORT_CAPTURE_BOUNDS_ID, - viewport_stack, - ) - .into(), + crate::ui::wrap_bar::PosReport::new(VIEWPORT_CAPTURE_BOUNDS_ID, viewport_stack).into(), ); if let Some(e) = right_edge { parts.push(e); @@ -1994,9 +1975,7 @@ bg={bg_ms:.1}ms n={view_count}" .style(|theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color( - palette.primary.base.color, - )), + background: Some(Background::Color(palette.primary.base.color)), text_color: Some(palette.primary.base.text), ..Default::default() } @@ -2007,9 +1986,7 @@ bg={bg_ms:.1}ms n={view_count}" .style(|theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color( - palette.background.base.color, - )), + background: Some(Background::Color(palette.background.base.color)), border: Border { color: palette.primary.base.color, width: 2.0, @@ -2023,12 +2000,8 @@ bg={bg_ms:.1}ms n={view_count}" .width(Fill) .height(Fill) .align_x(match side { - crate::app::config::DockSide::Left => { - iced::alignment::Horizontal::Left - } - crate::app::config::DockSide::Right => { - iced::alignment::Horizontal::Right - } + crate::app::config::DockSide::Left => iced::alignment::Horizontal::Left, + crate::app::config::DockSide::Right => iced::alignment::Horizontal::Right, }) .align_y(iced::alignment::Vertical::Top) .padding(iced::Padding { @@ -2043,9 +2016,7 @@ bg={bg_ms:.1}ms n={view_count}" .width(Fill) .height(Length::Fixed(3.0)) .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().primary.base.color, - )), + background: Some(Background::Color(theme.palette().primary.base.color)), ..Default::default() }), ) @@ -2065,12 +2036,8 @@ bg={bg_ms:.1}ms n={view_count}" .width(Fill) .height(Fill) .align_x(match side { - crate::app::config::DockSide::Left => { - iced::alignment::Horizontal::Left - } - crate::app::config::DockSide::Right => { - iced::alignment::Horizontal::Right - } + crate::app::config::DockSide::Left => iced::alignment::Horizontal::Left, + crate::app::config::DockSide::Right => iced::alignment::Horizontal::Right, }); stack![workspace, preview].width(Fill).height(Fill).into() } else { @@ -2215,11 +2182,7 @@ bg={bg_ms:.1}ms n={view_count}" current_scale_name: tab.scene.displayed_annotation_scale_name(), scale_list: tab.scene.scale_picker_list(), has_selection: !tab.scene.selected.is_empty(), - selection_types: tab - .scene - .entity_type_names_in_layout() - .as_ref() - .clone(), + selection_types: tab.scene.entity_type_names_in_layout().as_ref().clone(), selection_filter: &tab.scene.selection_filter, tooltip_hidden: self.status_menu_tooltip_hidden, }; @@ -2262,8 +2225,13 @@ bg={bg_ms:.1}ms n={view_count}" self.selection_cycling, &self.statusbar_config, status_menu_data, - tab.scene.sketch_constraint_set(tab.current_sketch_scope()).and_then(|s| s.dof), - tab.scene.sketch_constraint_set(tab.current_sketch_scope()).map(|s| s.conflicts.len()).unwrap_or(0), + tab.scene + .parametric_constraint_set(tab.current_parametric_scope()) + .and_then(|s| s.dof), + tab.scene + .parametric_constraint_set(tab.current_parametric_scope()) + .map(|s| s.conflicts.len()) + .unwrap_or(0), &self.gpu_status, ) }) @@ -2271,9 +2239,7 @@ bg={bg_ms:.1}ms n={view_count}" .height(Fill) }) .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.base.color - )), + background: Some(Background::Color(theme.palette().background.base.color)), ..Default::default() }) .width(Fill) @@ -2289,12 +2255,12 @@ bg={bg_ms:.1}ms n={view_count}" ) .unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into()); - let snap_override_layer: Element<'_, Message> = - if let Some(pos) = self.snap_override_popup { - overlay::snap_override_overlay(pos) - } else { - iced::widget::Space::new().width(0).height(0).into() - }; + let snap_override_layer: Element<'_, Message> = if let Some(pos) = self.snap_override_popup + { + overlay::snap_override_overlay(pos) + } else { + iced::widget::Space::new().width(0).height(0).into() + }; let qselect_layer: Element<'_, Message> = if let Some(state) = &self.qselect { qselect_overlay( @@ -2329,27 +2295,26 @@ bg={bg_ms:.1}ms n={view_count}" // If the Plot Style editor was launched from PLOT, keep the Plot dialog // rendered underneath as its blocked parent. - let modal_underlay: Element<'_, Message> = - if self.active_modal == Some(super::ModalKind::Plotstyle) { - if let Some((plot_offset, plot_resize)) = - self.plotstyle_parent_plot_geometry.as_ref() - { - let plot_content = self.plot_modal_content(*plot_resize); + let modal_underlay: Element<'_, Message> = if self.active_modal + == Some(super::ModalKind::Plotstyle) + { + if let Some((plot_offset, plot_resize)) = self.plotstyle_parent_plot_geometry.as_ref() { + let plot_content = self.plot_modal_content(*plot_resize); - crate::ui::modal::modal( - composed, - crate::tr!("modal", "plot"), - plot_content, - Message::CloseModal, - *plot_offset, - crate::ui::modal::ModalOptions::STANDARD, - ) - } else { - composed.into() - } + crate::ui::modal::modal( + composed, + crate::tr!("modal", "plot"), + plot_content, + Message::CloseModal, + *plot_offset, + crate::ui::modal::ModalOptions::STANDARD, + ) } else { composed.into() - }; + } + } else { + composed.into() + }; // ── In-canvas modal dialogs (Plan B) ─────────────────────────────── // Former pop-up windows render as overlays here, so they work on both @@ -2389,10 +2354,8 @@ bg={bg_ms:.1}ms n={view_count}" // is preceded by the preamble, reported as `head`. let mut detail = String::new(); if let Some((_, first)) = marks.first() { - let _ = std::fmt::Write::write_fmt( - &mut detail, - format_args!(" head={first:.1}"), - ); + let _ = + std::fmt::Write::write_fmt(&mut detail, format_args!(" head={first:.1}")); } for pair in marks.windows(2) { let _ = std::fmt::Write::write_fmt( @@ -2407,10 +2370,8 @@ bg={bg_ms:.1}ms n={view_count}" if let Some((_, current)) = self.color_pick_target.as_ref() { match self.color_picker_tab { super::ColorPickerTab::Index => { - let content = crate::ui::color_select::index_color_page( - *current, - &self.recent_colors, - ); + let content = + crate::ui::color_select::index_color_page(*current, &self.recent_colors); crate::ui::modal::modal( base, @@ -2424,22 +2385,19 @@ bg={bg_ms:.1}ms n={view_count}" super::ColorPickerTab::TrueColor => { let initial = crate::ui::properties::acad_color_display(*current).0; - let modal_base = - crate::ui::modal::backdrop(base, Message::CloseColorPicker); + let modal_base = crate::ui::modal::backdrop(base, Message::CloseColorPicker); iced_aw::ColorPicker::new( true, initial, modal_base, - // Cancel inside True Color returns to the indexed page instead // of closing the whole CAD colour picker. Message::ColorPickerTabChanged(super::ColorPickerTab::Index), - |color| { - Message::ColorWindowPick( - crate::ui::color_select::iced_to_acad_color(color), - ) + Message::ColorWindowPick(crate::ui::color_select::iced_to_acad_color( + color, + )) }, ) .into() @@ -2555,8 +2513,10 @@ impl OpenCADStudio { // the interval in minutes; 0 disables it. #[cfg(not(target_arch = "wasm32"))] let autosave = if self.savetime_min > 0 { - iced::time::every(std::time::Duration::from_secs(self.savetime_min as u64 * 60)) - .map(|_| Message::AutoSave) + iced::time::every(std::time::Duration::from_secs( + self.savetime_min as u64 * 60, + )) + .map(|_| Message::AutoSave) } else { Subscription::none() }; @@ -2728,7 +2688,8 @@ impl OpenCADStudio { #[cfg(not(target_arch = "wasm32"))] let control = super::control::subscribe().map(Message::ControlRequest); #[cfg(target_arch = "wasm32")] - let control = iced::time::every(std::time::Duration::from_millis(50)).map(|_|Message::PollWebControl); + let control = iced::time::every(std::time::Duration::from_millis(50)) + .map(|_| Message::PollWebControl); iced::Subscription::batch([ control, frames, @@ -2754,9 +2715,7 @@ impl OpenCADStudio { } pub(super) fn unfocus_widgets(&self) -> Task { - iced::advanced::widget::operate( - iced::advanced::widget::operation::focusable::unfocus(), - ) + iced::advanced::widget::operate(iced::advanced::widget::operation::focusable::unfocus()) } /// Build one edge: a narrow tab strip at the very edge plus the expanded @@ -2907,9 +2866,7 @@ impl OpenCADStudio { ) -> Element<'a, Message> { let auto_collapse = self.dock.auto_collapse(id); let panel: Element<'_, Message> = match id { - crate::ui::dock::PanelId::Properties => { - tab.properties.view(width, auto_collapse) - } + crate::ui::dock::PanelId::Properties => tab.properties.view(width, auto_collapse), crate::ui::dock::PanelId::BlockPalette => { crate::ui::window::block_palette::view(&self.block_palette, width, auto_collapse) } @@ -2983,10 +2940,10 @@ fn doc_tab_context_menu( } container(menu.spacing(0).width(MENU_W)) - .style(container::bordered_box) - .padding([4, 0]) - .width(iced::Length::Fixed(MENU_W)) - .into() + .style(container::bordered_box) + .padding([4, 0]) + .width(iced::Length::Fixed(MENU_W)) + .into() } pub(super) fn doc_tab_bar<'a>( @@ -3043,12 +3000,7 @@ pub(super) fn doc_tab_bar<'a>( let title_btn: Element<'_, Message> = if tab.is_start { title_btn.into() } else { - crate::ui::wrap_bar::ReorderTab::document( - idx, - drag_targets.clone(), - title_btn, - ) - .into() + crate::ui::wrap_bar::ReorderTab::document(idx, drag_targets.clone(), title_btn).into() }; // Start tab is fixed — no close button. Every other tab gets a close. @@ -3156,10 +3108,7 @@ pub(super) fn doc_tab_bar<'a>( .padding([4, 8]) .style(|theme: &Theme, status| { let palette = theme.palette(); - let hovered = matches!( - status, - button::Status::Hovered | button::Status::Pressed - ); + let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); button::Style { background: Some(Background::Color(if hovered { palette.background.weak.color @@ -3195,20 +3144,18 @@ pub(super) fn doc_tab_bar<'a>( .wrap() .vertical_spacing(2.0), ) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.base.color, - )), - border: Border { - color: theme.palette().background.neutral.color, - width: 1.0, - radius: 0.0.into(), - }, - ..Default::default() - }) - .width(Fill) - .padding([2, 2]) - .into() + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }) + .width(Fill) + .padding([2, 2]) + .into() } // ── Layout context-menu overlay ──────────────────────────────────────────── @@ -3308,9 +3255,7 @@ fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { .width(Length::Fixed(5.0)) .height(Fill) .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color, - )), + background: Some(Background::Color(theme.palette().background.neutral.color)), ..Default::default() }); let grab = Message::Dock(crate::ui::dock::DockMsg::ResizeGrab(id)); @@ -3337,10 +3282,7 @@ pub(super) fn start_page_view<'a>( discussions: &'a [crate::discussions::DiscussionEntry], discussions_loading: bool, recents: &'a [std::path::PathBuf], - thumbs: &'a std::collections::HashMap< - std::path::PathBuf, - Option, - >, + thumbs: &'a std::collections::HashMap>, recent_limit: usize, recent_limit_input: &'a str, action_width_out: std::sync::Arc, @@ -3374,10 +3316,7 @@ fn start_page_content<'a>( discussions: &'a [crate::discussions::DiscussionEntry], discussions_loading: bool, recents: &'a [std::path::PathBuf], - thumbs: &'a std::collections::HashMap< - std::path::PathBuf, - Option, - >, + thumbs: &'a std::collections::HashMap>, recent_limit: usize, recent_limit_input: &'a str, avail_w: f32, @@ -3449,14 +3388,15 @@ fn start_page_content<'a>( .into(), outline_btn(crate::tr!("action", "options"), Message::OptionsOpen).into(), ]; - secondary_items.push(outline_btn(crate::tr!("action", "plugins"), Message::PluginManagerOpen).into()); + secondary_items + .push(outline_btn(crate::tr!("action", "plugins"), Message::PluginManagerOpen).into()); // The web build is already in the browser, so only the desktop offers a // link to the web version. #[cfg(not(target_arch = "wasm32"))] { // Filled with the active theme's primary colour. secondary_items.push( - button(text(crate::t!("OCS Web")).size(14)) + button(text(crate::t!("OCS Web")).size(14)) .on_press(Message::RibbonToolClick { tool_id: "WEBVERSION".to_string(), event: crate::modules::ModuleEvent::Command("WEBVERSION".to_string()), @@ -3468,7 +3408,7 @@ fn start_page_content<'a>( } #[cfg(target_arch = "wasm32")] secondary_items.push( - button(text(crate::t!("OCS Desktop")).size(14)) + button(text(crate::t!("OCS Desktop")).size(14)) .on_press(Message::OpenUrl( "https://github.com/HakanSeven12/OpenCADStudio/releases/latest".to_string(), )) @@ -3533,8 +3473,8 @@ fn start_page_content<'a>( mouse_area( container( iced::widget::image(MOBILE_SPONSOR_IMAGE.clone()) - .width(Fill) - .content_fit(iced::ContentFit::Contain), + .width(Fill) + .content_fit(iced::ContentFit::Contain), ) .width(Fill), ) @@ -3577,9 +3517,8 @@ fn start_page_content<'a>( let panel_w = 280.0f32; const VIDEO_PANEL_PADDING: f32 = 16.0; const VIDEO_SCROLL_GUTTER: f32 = 14.0; - let measured_action_w = f32::from_bits( - action_width_out.load(std::sync::atomic::Ordering::Relaxed) - ); + let measured_action_w = + f32::from_bits(action_width_out.load(std::sync::atomic::Ordering::Relaxed)); let welcome_wide_min = measured_action_w.max(360.0); let avail = (avail_w - 16.0).max(0.0); // minus the page's l/r padding let panel_widths = [panel_w; 4]; @@ -3630,8 +3569,7 @@ fn start_page_content<'a>( let videos_panel: Element<'a, Message> = { // Derive the 16:9 cover box from the actual shared list width so the // whole thumbnail remains visible when that width changes. - let thumb_h = - (panel_w - VIDEO_PANEL_PADDING * 2.0 - VIDEO_SCROLL_GUTTER) * 9.0 / 16.0; + let thumb_h = (panel_w - VIDEO_PANEL_PADDING * 2.0 - VIDEO_SCROLL_GUTTER) * 9.0 / 16.0; let mut list = column![text(crate::tr!("start", "tutorials")).size(15)] .spacing(10) .width(Fill) @@ -3680,22 +3618,22 @@ fn start_page_content<'a>( } let playlist_btn = mouse_area( container(text(crate::tr!("start", "open-playlist")).size(12)) - .padding([6, 10]) - .width(Fill) - .center_x(Fill) - .style(|theme: &Theme| { - let pair = theme.palette().danger.base; - container::Style { - background: Some(Background::Color(pair.color)), - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 6.0.into(), - }, - text_color: Some(pair.text), - ..Default::default() - } - }), + .padding([6, 10]) + .width(Fill) + .center_x(Fill) + .style(|theme: &Theme| { + let pair = theme.palette().danger.base; + container::Style { + background: Some(Background::Color(pair.color)), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 6.0.into(), + }, + text_color: Some(pair.text), + ..Default::default() + } + }), ) .interaction(iced::mouse::Interaction::Pointer) .on_press(Message::OpenUrl(crate::videos::PLAYLIST_URL.to_string())); @@ -3716,13 +3654,13 @@ fn start_page_content<'a>( .style(|theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 8.0.into(), - }, - ..Default::default() + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() } }) .into() @@ -3736,11 +3674,9 @@ fn start_page_content<'a>( .spacing(8) .width(Fill); for discussion in discussions { - let mut meta = iced::widget::row![ - text(format!("#{}", discussion.number)) - .size(10) - .style(start_muted_style), - ] + let mut meta = iced::widget::row![text(format!("#{}", discussion.number)) + .size(10) + .style(start_muted_style),] .spacing(6) .align_y(iced::Center); if discussion.pinned { @@ -3757,29 +3693,24 @@ fn start_page_content<'a>( .style(start_muted_style), ); } - let card = container( - column![ - text(discussion.title.clone()).size(12), - meta, - ] - .spacing(4), - ) - .padding([8, 10]) - .width(Fill) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color( - palette.background.base.color.scale_alpha(0.42), - )), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 6.0.into(), - }, - ..Default::default() - } - }); + let card = + container(column![text(discussion.title.clone()).size(12), meta,].spacing(4)) + .padding([8, 10]) + .width(Fill) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color( + palette.background.base.color.scale_alpha(0.42), + )), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + } + }); list = list.push( mouse_area(card) .interaction(iced::mouse::Interaction::Pointer) @@ -3823,9 +3754,7 @@ fn start_page_content<'a>( open_btn, ]) .width(match start_layout { - StartLayout::AllPanels | StartLayout::WithoutVideos => { - iced::Length::Fixed(panel_w) - } + StartLayout::AllPanels | StartLayout::WithoutVideos => iced::Length::Fixed(panel_w), StartLayout::WithoutVideosAndDiscussions | StartLayout::RecentAndWelcome | StartLayout::Compact => iced::Length::Fill, @@ -3888,14 +3817,14 @@ fn start_page_content<'a>( .style(|theme: &Theme| { let pair = theme.palette().danger.base; container::Style { - background: Some(Background::Color(pair.color)), - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 6.0.into(), - }, - text_color: Some(pair.text), - ..Default::default() + background: Some(Background::Color(pair.color)), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 6.0.into(), + }, + text_color: Some(pair.text), + ..Default::default() } }), ) @@ -3911,52 +3840,43 @@ fn start_page_content<'a>( .width(match start_layout { StartLayout::AllPanels | StartLayout::WithoutVideos - | StartLayout::WithoutVideosAndDiscussions => { - iced::Length::Fixed(panel_w) - } - StartLayout::RecentAndWelcome - | StartLayout::Compact => iced::Length::Fill, + | StartLayout::WithoutVideosAndDiscussions => iced::Length::Fixed(panel_w), + StartLayout::RecentAndWelcome | StartLayout::Compact => iced::Length::Fill, }) .height(Fill) .padding(20) .style(|theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 8.0.into(), - }, - ..Default::default() + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() } }) .into() }; let body: Element<'a, Message> = match start_layout { - StartLayout::AllPanels => iced::widget::row![ - recent, - videos_panel, - welcome, - discussions_panel, - supporters, - ] - .spacing(16) - .height(Fill) - .into(), + StartLayout::AllPanels => { + iced::widget::row![recent, videos_panel, welcome, discussions_panel, supporters,] + .spacing(16) + .height(Fill) + .into() + } StartLayout::WithoutVideos => { iced::widget::row![recent, welcome, discussions_panel, supporters] .spacing(16) .height(Fill) .into() } - StartLayout::WithoutVideosAndDiscussions => { - iced::widget::row![recent, welcome, supporters] - .spacing(16) - .height(Fill) - .into() - } + StartLayout::WithoutVideosAndDiscussions => iced::widget::row![recent, welcome, supporters] + .spacing(16) + .height(Fill) + .into(), StartLayout::RecentAndWelcome => iced::widget::row![recent, welcome] .spacing(16) .height(Fill) @@ -3971,35 +3891,45 @@ fn start_page_content<'a>( let palette = theme.palette(); let pair = match (is_active, status) { (true, _) => Some(palette.primary.weak), - (false, button::Status::Hovered) => { - Some(palette.background.strong) - } + (false, button::Status::Hovered) => Some(palette.background.strong), _ => None, }; button::Style { - background: pair.map(|p| Background::Color(p.color)), - text_color: pair - .map(|p| p.text) - .unwrap_or(palette.background.base.text.scale_alpha(0.68)), - border: Border { - color: if is_active { - palette.primary.base.color - } else { - Color::TRANSPARENT + background: pair.map(|p| Background::Color(p.color)), + text_color: pair + .map(|p| p.text) + .unwrap_or(palette.background.base.text.scale_alpha(0.68)), + border: Border { + color: if is_active { + palette.primary.base.color + } else { + Color::TRANSPARENT + }, + width: if is_active { 1.0 } else { 0.0 }, + radius: 6.0.into(), }, - width: if is_active { 1.0 } else { 0.0 }, - radius: 6.0.into(), - }, - ..Default::default() + ..Default::default() } }) }; let tab_bar = Row::with_children(vec![ - tab_btn(crate::tr!("start", "recent-files"), super::StartSection::Recent).into(), + tab_btn( + crate::tr!("start", "recent-files"), + super::StartSection::Recent, + ) + .into(), tab_btn(crate::tr!("start", "videos"), super::StartSection::Videos).into(), tab_btn(crate::tr!("start", "welcome"), super::StartSection::Welcome).into(), - tab_btn(crate::tr!("start", "discussions"), super::StartSection::Discussions).into(), - tab_btn(crate::tr!("start", "supporters"), super::StartSection::Supporters).into(), + tab_btn( + crate::tr!("start", "discussions"), + super::StartSection::Discussions, + ) + .into(), + tab_btn( + crate::tr!("start", "supporters"), + super::StartSection::Supporters, + ) + .into(), ]) .spacing(6.0) .align_y(iced::Center) @@ -4040,21 +3970,19 @@ fn start_page_content<'a>( }; container(body) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.base.color - )), - ..Default::default() - }) - .padding(iced::Padding { - top: 16.0, - right: 8.0, - bottom: 16.0, - left: 8.0, - }) - .width(Fill) - .height(Fill) - .into() + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + ..Default::default() + }) + .padding(iced::Padding { + top: 16.0, + right: 8.0, + bottom: 16.0, + left: 8.0, + }) + .width(Fill) + .height(Fill) + .into() } // ── Recent Documents panel (Start tab left rail) ────────────────────────── @@ -4065,10 +3993,7 @@ fn start_page_content<'a>( // open — entries persist across sessions. pub(super) fn recent_files_panel<'a>( recents: &'a [std::path::PathBuf], - thumbs: &'a std::collections::HashMap< - std::path::PathBuf, - Option, - >, + thumbs: &'a std::collections::HashMap>, limit: usize, limit_input: &'a str, width: iced::Length, @@ -4081,10 +4006,10 @@ pub(super) fn recent_files_panel<'a>( container( text(crate::tr!("start", "no-recent-files")) .size(12) - .style(start_muted_style) + .style(start_muted_style), ) - .height(Fill) - .into() + .height(Fill) + .into() } else { // Right padding reserves a gutter for the scrollbar so it doesn't sit on // top of the row's ✕ remove button. @@ -4132,8 +4057,7 @@ pub(super) fn recent_files_panel<'a>( row![ thumb, column![ - text(crate::ui::text_util::elide(&name, 28)) - .size(12), + text(crate::ui::text_util::elide(&name, 28)).size(12), text(crate::ui::text_util::elide(&dir, 38)) .size(10) .style(start_muted_style), @@ -4149,16 +4073,15 @@ pub(super) fn recent_files_panel<'a>( .style(move |theme: &Theme, status| { let palette = theme.palette(); button::Style { - background: matches!(status, button::Status::Hovered).then_some( - Background::Color(palette.background.strong.color) - ), - text_color: palette.background.base.text, - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 0.0.into(), - }, - ..Default::default() + background: matches!(status, button::Status::Hovered) + .then_some(Background::Color(palette.background.strong.color)), + text_color: palette.background.base.text, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 0.0.into(), + }, + ..Default::default() } }); @@ -4167,11 +4090,11 @@ pub(super) fn recent_files_panel<'a>( crate::ui::icons::CLOSE, 11.0, )) - .on_press(Message::RecentRemove(path_for_remove)) - .padding([4, 8]) - .style(|theme: &Theme, status| { - let palette = theme.palette(); - button::Style { + .on_press(Message::RecentRemove(path_for_remove)) + .padding([4, 8]) + .style(|theme: &Theme, status| { + let palette = theme.palette(); + button::Style { background: matches!(status, button::Status::Hovered) .then_some(Background::Color(palette.danger.weak.color)), text_color: palette.background.base.text.scale_alpha(0.68), @@ -4181,8 +4104,8 @@ pub(super) fn recent_files_panel<'a>( radius: 3.0.into(), }, ..Default::default() - } - }); + } + }); col = col.push(row![open_btn, remove_btn].spacing(0).align_y(iced::Center)); } @@ -4197,16 +4120,15 @@ pub(super) fn recent_files_panel<'a>( let step_style = |theme: &Theme, status: button::Status| { let palette = theme.palette(); button::Style { - background: matches!(status, button::Status::Hovered).then_some( - Background::Color(palette.background.strong.color) - ), - text_color: palette.background.base.text, - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() + background: matches!(status, button::Status::Hovered) + .then_some(Background::Color(palette.background.strong.color)), + text_color: palette.background.base.text, + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() } }; // +/- step from whatever is currently shown in the box (mid-edit included). @@ -4218,7 +4140,10 @@ pub(super) fn recent_files_panel<'a>( .padding([2, 6]) .width(iced::Length::Fixed(46.0)); let limit_row = row![ - text(crate::tr!("start", "keep-recent-files")).size(11).style(start_muted_style).width(Fill), + text(crate::tr!("start", "keep-recent-files")) + .size(11) + .style(start_muted_style) + .width(Fill), button(crate::ui::icons::themed(crate::ui::icons::MINUS, 11.0)) .on_press(Message::SetRecentLimit(shown.saturating_sub(STEP))) .padding([3, 6]) @@ -4251,13 +4176,13 @@ pub(super) fn recent_files_panel<'a>( .style(|theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 8.0.into(), - }, - ..Default::default() + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() } }) .into() diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index ba6a6db69..218fe3e4d 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -201,7 +201,6 @@ impl OpenCADStudio { crate::ui::window::options::view_window( &self.default_save_format, self.file_assoc_enabled, - self.write_dwg_native_constraints, self.show_constraint_values, &self.ui_theme, &self.theme_color_inputs, diff --git a/src/command.rs b/src/command.rs index e5b43ea76..b4e9da1df 100644 --- a/src/command.rs +++ b/src/command.rs @@ -25,10 +25,15 @@ pub enum HatchEditOperation { style: Option, annotative: Option, }, - RecreateBoundary { associate: bool, region: bool }, + RecreateBoundary { + associate: bool, + region: bool, + }, BeginAssociate, AssociatePaths(Vec), - DrawOrderBoundary { above: bool }, + DrawOrderBoundary { + above: bool, + }, Separate, AddBoundaries(Vec), RemoveBoundaries(Vec), @@ -102,8 +107,7 @@ impl WorkingPlane { pub fn angle(self, from: DVec3, to: DVec3) -> Option { let direction = self.vector_to_local(to - from); - (direction.x.hypot(direction.y) > f64::EPSILON) - .then(|| direction.y.atan2(direction.x)) + (direction.x.hypot(direction.y) > f64::EPSILON).then(|| direction.y.atan2(direction.x)) } pub fn to_world_transform(self) -> acadrust::types::Transform { @@ -493,16 +497,18 @@ impl CadCommand for UserRegCommand { fn prompt(&self) -> String { match self.slot { - None => crate::t!("%{name} which register? [1-5]:", name = self.name) - .into_owned(), - Some(n) => crate::t!("%{name}%{slot} new value:", name = self.name, slot = n) - .into_owned(), + None => crate::t!("%{name} which register? [1-5]:", name = self.name).into_owned(), + Some(n) => { + crate::t!("%{name}%{slot} new value:", name = self.name, slot = n).into_owned() + } } } fn options(&self) -> Vec { match self.slot { - None => (1..=5).map(|n| CmdOption::new(&n.to_string(), &n.to_string())).collect(), + None => (1..=5) + .map(|n| CmdOption::new(&n.to_string(), &n.to_string())) + .collect(), Some(_) => Vec::new(), } } @@ -593,9 +599,10 @@ fn match_cmd_option<'a>( return None; } // 1. Exact match on keyword or label (case-insensitive) - if let Some(opt) = options.iter().find(|(label, k, _)| { - k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t) - }) { + if let Some(opt) = options + .iter() + .find(|(label, k, _)| k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t)) + { return Some(opt); } // 2. Unambiguous prefix match on keyword or label (e.g. "A" -> "ABOVE", "L" -> "LEFT") @@ -650,8 +657,7 @@ impl CadCommand for KeywordCommand { // Consumed inputs that keep prompting return `Some(NeedPoint)` — // `None` would hand the same text to the command a second time. None => { - let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) - else { + let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) else { // Unknown verb — keep prompting rather than dispatch garbage. return Some(CmdResult::NeedPoint); }; @@ -809,7 +815,7 @@ impl CadCommand for Mid2PointCommand { WireModel::CYAN, false, )); - // Midpoint marker (AutoCAD style triangle glyph) + // Midpoint marker (triangle glyph) let mid = (first + pt) * 0.5; let dist = (pt - first).length(); let s = (dist * 0.02).clamp(0.5, 10.0); @@ -834,7 +840,6 @@ inventory::submit!(CommandRegistration { names: &["MTP", "M2P"], }); - /// Generic interactive front-end for a keyword command that operates on the /// current selection (CHPROP, ADJUST, XDATA, UNDERLAY, DRAWORDER…). If nothing /// is selected when it starts it first gathers a selection (Enter confirms), @@ -942,8 +947,7 @@ impl CadCommand for SelectThenKeywordCommand { } } None => { - let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) - else { + let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) else { // Unknown verb — consumed, keep prompting (`None` would // feed the same text to the command a second time). return Some(CmdResult::NeedPoint); @@ -1405,7 +1409,10 @@ pub enum CmdResult { /// Copy selected entities with a transform; command stays active for more copies. CopySelected(Vec, EntityTransform), /// Store selected entities in the shared clipboard; command stays active. - CopyToClipboard { handles: Vec, base: DVec3 }, + CopyToClipboard { + handles: Vec, + base: DVec3, + }, /// Commit a hatch fill (stored in Scene::hatches, not the DXF document). CommitHatch(HatchModel), /// Commit a hatch with the selected hatch's entity colour and transparency. @@ -1430,22 +1437,25 @@ pub enum CmdResult { /// Erase `handle` and replace with new entities; command stays active. ReplaceEntity(Handle, Vec), /// Update one entity in place, preserve its handle, and end the command. - UpdateEntityAndFinish { handle: Handle, entity: EntityType }, + UpdateEntityAndFinish { + handle: Handle, + entity: EntityType, + }, /// Replace / delete multiple entities and add new ones; command ends. /// Each pair: (handle_to_erase, replacement_entities) — empty vec = delete only. ReplaceMany(Vec<(Handle, Vec)>, Vec), /// Replace several entities as one undo step while keeping the command active. ReplaceManyContinue(Vec<(Handle, Vec)>), /// Add a persistent parametric constraint to the current scope's - /// `SketchConstraintSet` and trigger its + /// `ParametricConstraintSet` and trigger its /// first solve; end the command. Unlike `ReplaceMany`/`CommitEntity`, /// nothing here is geometry to add or replace directly — the host adds /// the constraint record, then re-solves through `Scene::bump_entities`'s - /// existing chain (`refresh_sketch_constraints`) the same way any later + /// existing chain (`refresh_parametric_constraints`) the same way any later /// edit to these entities will. - AddSketchConstraint { - kind: crate::scene::sketch_constraints::ConstraintKind, - refs: Vec, + AddParametricConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind, + refs: Vec, /// The typed target for a dimensional kind (Distance/Angle/Radius) — /// a literal number, or a named-parameter reference recognized by /// `DistanceConstraintCommand`/`AngleConstraintCommand:: @@ -1468,7 +1478,7 @@ pub enum CmdResult { /// (`crate::modules::parametric::point_on_entity`) between one /// picked point and `target`, a whole entity selected before the tool /// ran. Like `AddCoincidentConstraint`, the host resolves `point` via - /// `sketch_constraints::nearest_sketch_point` (a `CadCommand` has no + /// `parametric_constraints::nearest_parametric_point` (a `CadCommand` has no /// document access) and, for `CenterPoint` specifically, addresses /// `target` via its center marker rather than as a whole entity — see /// `ConstraintKind::CenterPoint`'s own doc comment for why it's the @@ -1477,14 +1487,14 @@ pub enum CmdResult { AddPointOnEntityConstraint { point: DVec3, target: Handle, - kind: crate::scene::sketch_constraints::ConstraintKind, + kind: crate::scene::parametric_constraints::ConstraintKind, label: &'static str, }, /// Add a persistent `EqualDistance` constraint /// (`crate::modules::parametric::equal_distance`): the distance /// between `points[0]`/`points[1]` equals the distance between /// `points[2]`/`points[3]`. The host resolves each point via - /// `sketch_constraints::nearest_sketch_point`, same reasoning as + /// `parametric_constraints::nearest_parametric_point`, same reasoning as /// `AddCoincidentConstraint`. AddEqualDistanceConstraint { points: [DVec3; 4], @@ -1516,7 +1526,10 @@ pub enum CmdResult { /// it owns the document needed for the table-style lookup — honors /// content locks, and launches the cell editor, re-prompting the /// command when the pick misses a cell. - EditTableCell { handle: Handle, point: DVec3 }, + EditTableCell { + handle: Handle, + point: DVec3, + }, /// Cancel because the active drawing space changed. Cleanup is identical /// to `Cancel`, but the host reports the context change explicitly. CancelForSpaceChange, @@ -1539,13 +1552,24 @@ pub enum CmdResult { /// `Relaunch` it does not touch the selection. Dispatch(String), /// Move `dest` entities to the layer of the `src` entity; end command. - MatchEntityLayer { dest: Vec, src: Handle }, + MatchEntityLayer { + dest: Vec, + src: Handle, + }, /// Copy all visual properties (layer/color/linetype/lineweight) from `src` to `dest`; end command. - MatchProperties { dest: Vec, src: Handle }, + MatchProperties { + dest: Vec, + src: Handle, + }, /// Create a named group from the given entity handles; end command. - CreateGroup { handles: Vec, name: String }, + CreateGroup { + handles: Vec, + name: String, + }, /// Dissolve all groups that contain any of the given handles; end command. - DeleteGroups { handles: Vec }, + DeleteGroups { + handles: Vec, + }, /// Freeze or thaw layers by name in the given viewport; command stays active. VpLayerUpdate { vp_handle: Handle, @@ -1553,9 +1577,14 @@ pub enum CmdResult { thaw: Vec, }, /// Paste clipboard entities translated so their centroid lands at `base_pt`; end command. - PasteClipboard { base_pt: DVec3 }, + PasteClipboard { + base_pt: DVec3, + }, /// Zoom the model-space camera to fit the given corner points; end command. - ZoomToWindow { p1: DVec3, p2: DVec3 }, + ZoomToWindow { + p1: DVec3, + p2: DVec3, + }, /// Print a measurement result to the command line and end the command. Measurement(String), /// Print a measurement result and keep the command active. @@ -1567,20 +1596,36 @@ pub enum CmdResult { /// Clear the current selection and keep the command active at its updated step. DeselectAndContinue, /// Break `handle` at points `p1` and `p2`; replace with computed fragments. - BreakEntity { handle: Handle, p1: DVec3, p2: DVec3 }, + BreakEntity { + handle: Handle, + p1: DVec3, + p2: DVec3, + }, /// Attempt to join the given entities into fewer merged entities. JoinEntities(Vec), /// Join candidates into an explicitly selected source. - JoinToSource { source: Handle, handles: Vec }, + JoinToSource { + source: Handle, + handles: Vec, + }, /// Apply a polyline-edit operation to one entity; keep command active. PeditOp { handle: Handle, op: crate::modules::draw::modify::pedit::PeditOp, }, /// Place Point entities at N equal intervals along the entity. - DivideEntity { handle: Handle, n: usize, marker: Option }, + DivideEntity { + handle: Handle, + n: usize, + marker: Option, + }, /// Place Point entities at `segment_length` intervals along the entity. - MeasureEntity { handle: Handle, segment_length: f64, pick_point: DVec3, marker: Option }, + MeasureEntity { + handle: Handle, + segment_length: f64, + pick_point: DVec3, + marker: Option, + }, /// Extend/trim a Line or Arc by the given mode; end command. LengthenEntity { handle: Handle, @@ -1596,7 +1641,10 @@ pub enum CmdResult { scale: f64, }, /// Set the plot window on the active layout's PlotSettings. - SetPlotWindow { p1: DVec3, p2: DVec3 }, + SetPlotWindow { + p1: DVec3, + p2: DVec3, + }, /// Create a paper-space viewport. `preserve_view` keeps an explicitly /// selected/defined view instead of applying the normal model-extents fit. MviewCreate { @@ -1623,7 +1671,10 @@ pub enum CmdResult { /// Quick-print the bounding box of the given selected entities to a PDF. QuickPrint(Vec), /// Replace the text content of a Text/MText entity in-place. - DdeditEntity { handle: Handle, new_text: String }, + DdeditEntity { + handle: Handle, + new_text: String, + }, /// Apply one DIMEDIT operation to every selected dimension. EditDimensions { handles: Vec, @@ -1631,7 +1682,9 @@ pub enum CmdResult { }, /// Open the in-place editor (plain box or rich MText editor, per type) for /// a text-bearing entity picked by a command such as DDEDIT. - EditTextEntity { handle: Handle }, + EditTextEntity { + handle: Handle, + }, /// Open the in-place MText editor (formatting toolbar + multi-line text /// area with live viewport preview). `handle` is `Some` when editing an /// existing MText, `None` when creating a new one at `pos`. @@ -1784,7 +1837,9 @@ pub enum CmdResult { /// INSERT landed on a block that has AttributeDefinitions. /// The host should look up the attdefs for `block_name` from the document /// and call `attreq_set_attdefs()` on the command, then loop on text input. - AttreqNeeded { block_name: String }, + AttreqNeeded { + block_name: String, + }, /// Add a command-owned "live" entity to the document mid-command and hand /// its assigned handle back to the active command via `set_live_handle()`. /// One undo snapshot is pushed here, so the whole in-progress object reverts @@ -1808,7 +1863,9 @@ pub enum CmdResult { /// PLINE's Undo popping back below the two vertices an entity needs. RemoveLiveEntity(Handle), /// Suspends command execution, moves it to suspended_cmd, and opens the text editor for the given handle. - SuspendForTextEdit { handle: Handle }, + SuspendForTextEdit { + handle: Handle, + }, /// Requests a standard document-level undo while keeping the command active. UndoDocument, /// Sets the TEXTEDITMODE system variable and ends the command. @@ -2048,7 +2105,6 @@ pub struct CurveMarker { pub plane: WorkingPlane, } - /// Current screen projection and configured aperture for point-feature picking. #[derive(Clone, Copy)] pub struct PointPickContext { @@ -2060,10 +2116,16 @@ pub struct PointPickContext { pub trait CadCommand: Send { /// Preserve source appearance for commands that extract existing entities. - fn preserve_commit_style(&self) -> bool { false } - fn nested_copy_bind_setting(&self) -> Option { None } + fn preserve_commit_style(&self) -> bool { + false + } + fn nested_copy_bind_setting(&self) -> Option { + None + } /// Symbol localization is committed together with the extracted entities. - fn nested_copy_symbol_names(&self) -> Option<&acadrust::nested_copy::NestedCopySymbolNames> { None } + fn nested_copy_symbol_names(&self) -> Option<&acadrust::nested_copy::NestedCopySymbolNames> { + None + } /// Keep the layer already carried by entities committed by this command /// instead of replacing it with the current drawing layer. fn preserve_commit_layer(&self) -> bool { @@ -2131,7 +2193,9 @@ pub trait CadCommand: Send { fn on_point(&mut self, pt: DVec3) -> CmdResult; /// Opt in only while a point input selects an existing point feature. - fn wants_point_pick_context(&self) -> bool { false } + fn wants_point_pick_context(&self) -> bool { + false + } /// Refreshed for each point input so zoom and viewport changes are reflected. fn set_point_pick_context(&mut self, _context: Option) {} @@ -2203,7 +2267,10 @@ pub trait CadCommand: Send { } fn on_deferred_entity_hover( - &mut self, _scene: &Scene, _handle: Option, _point: DVec3, + &mut self, + _scene: &Scene, + _handle: Option, + _point: DVec3, ) -> Vec { Vec::new() } @@ -2417,7 +2484,9 @@ pub trait CadCommand: Send { } /// Exclude locked-layer entities from injected selection geometry. - fn selection_entities_exclude_locked(&self) -> bool { false } + fn selection_entities_exclude_locked(&self) -> bool { + false + } fn inject_selection_entities(&mut self, _entities: Vec) {} @@ -2425,9 +2494,7 @@ pub trait CadCommand: Send { None } - fn hatch_preview_models( - &self, - ) -> Option> { + fn hatch_preview_models(&self) -> Option> { None } @@ -2632,7 +2699,10 @@ mod constraint_registry_tests { "DCONSTRAINT", "ACONSTRAINT", ] { - assert!(names.contains(&id), "{id} is missing from the command registry"); + assert!( + names.contains(&id), + "{id} is missing from the command registry" + ); } } } diff --git a/src/modules/parametric/coincident.rs b/src/modules/parametric/coincident.rs index eccad85ba..356c6b556 100644 --- a/src/modules/parametric/coincident.rs +++ b/src/modules/parametric/coincident.rs @@ -8,7 +8,7 @@ //! comment for the same constraint on `DCONSTRAINT`/`ACONSTRAINT`), so this //! command only accumulates the two raw points `on_point` receives and hands //! them back as `CmdResult::AddCoincidentConstraint` — the host resolves -//! each point to a real `SketchRef` (`sketch_constraints::nearest_sketch_point`) +//! each point to a real `ParametricRef` (`parametric_constraints::nearest_parametric_point`) //! and adds the constraint, the same split `ReassociateCenterMark` already //! uses for a similar reason. diff --git a/src/modules/parametric/point_on_entity.rs b/src/modules/parametric/point_on_entity.rs index 1ce9ac91e..4bf7cd0a3 100644 --- a/src/modules/parametric/point_on_entity.rs +++ b/src/modules/parametric/point_on_entity.rs @@ -7,7 +7,7 @@ //! endpoint) so it doesn't need interactive picking; the point side does //! (which of a line's two endpoints, or some other addressable point), so //! this command accumulates just that one point pick and hands it back for -//! the host to resolve via `nearest_sketch_point` — the same split +//! the host to resolve via `nearest_parametric_point` — the same split //! `CoincidentConstraintCommand` uses and for the same reason (`CadCommand` //! has no document access). @@ -15,7 +15,7 @@ use glam::DVec3; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; -use crate::scene::sketch_constraints::ConstraintKind; +use crate::scene::parametric_constraints::ConstraintKind; use acadrust::types::Handle; pub mod center_point_tool { diff --git a/src/modules/parametric/tools.rs b/src/modules/parametric/tools.rs index 32b886b3c..9a0a57293 100644 --- a/src/modules/parametric/tools.rs +++ b/src/modules/parametric/tools.rs @@ -64,9 +64,7 @@ pub mod equal { ToolDef { id: "ECONSTRAINT", label: "Equal", - icon: IconKind::Svg(include_bytes!( - "../../../assets/icons/constrain/equal.svg" - )), + icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/equal.svg")), event: ModuleEvent::Command("ECONSTRAINT".to_string()), } } @@ -105,7 +103,7 @@ pub mod concentric { /// "the line passes through the circle's center" (a circle's radius is /// always normal to its own tangent), so it's built from the same /// `PointOnLine` primitive `PointOnCurve` already uses for a point-on-line -/// case (`sketch_solve.rs`). Distinct from `Perpendicular`, which only +/// case (`parametric_solve.rs`). Distinct from `Perpendicular`, which only /// covers line-to-line. pub mod normal { use super::*; @@ -113,9 +111,7 @@ pub mod normal { ToolDef { id: "NRCONSTRAINT", label: "Normal", - icon: IconKind::Svg(include_bytes!( - "../../../assets/icons/constrain/normal.svg" - )), + icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/normal.svg")), event: ModuleEvent::Command("NRCONSTRAINT".to_string()), } } @@ -141,9 +137,7 @@ pub mod fixed { ToolDef { id: "FXCONSTRAINT", label: "Fixed", - icon: IconKind::Svg(include_bytes!( - "../../../assets/icons/constrain/fixed.svg" - )), + icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/fixed.svg")), event: ModuleEvent::Command("FXCONSTRAINT".to_string()), } } diff --git a/src/modules/parametric/value.rs b/src/modules/parametric/value.rs index 068923701..de84df66a 100644 --- a/src/modules/parametric/value.rs +++ b/src/modules/parametric/value.rs @@ -4,8 +4,8 @@ //! //! These commands don't solve anything themselves. A `CadCommand`'s `on_text_input` only //! gets `&mut self` — no document access — so it can't add a -//! `SketchConstraint` to the scene directly; instead these commands hand the -//! typed value back as `CmdResult::AddSketchConstraint`, and the host (which +//! `ParametricConstraint` to the scene directly; instead these commands hand the +//! typed value back as `CmdResult::AddParametricConstraint`, and the host (which //! does have `&mut Scene`) adds the record and solves it via the same //! `Scene::bump_entities` path any later edit to these entities will use too. //! `default_value` (what the prompt shows in `<...>`, and what a bare Enter @@ -19,7 +19,7 @@ use glam::DVec3; use crate::command::{CadCommand, CmdResult, InputKind}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::named_parameters::DrivingValue; -use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; +use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; use crate::scene::Scene; /// Recognizes a typed prompt token as either a numeric literal or a @@ -28,7 +28,7 @@ use crate::scene::Scene; /// ::new`, which already has `&Scene`) since `on_text_input` itself has no /// document access — the same constraint `default_value` already works /// around. Only *existence* is checked here; the actual value is resolved -/// fresh at solve time (`sketch_solve::build_constraint`), consistent with +/// fresh at solve time (`parametric_solve::build_constraint`), consistent with /// this project's "no incremental/cached resolution" approach throughout. fn parse_driving_value(text: &str, known_names: &[String]) -> Option { let text = text.trim(); @@ -64,17 +64,70 @@ pub mod dimensional_tools { use super::*; fn command(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef { - ToolDef { id, label, icon: IconKind::Svg(icon), event: ModuleEvent::Command(id.to_string()) } + ToolDef { + id, + label, + icon: IconKind::Svg(icon), + event: ModuleEvent::Command(id.to_string()), + } } - pub fn linear() -> ToolDef { command("DCLINEAR", "Linear", include_bytes!("../../../assets/icons/dim_linear.svg")) } - pub fn horizontal() -> ToolDef { command("DCHORIZONTAL", "Horizontal", include_bytes!("../../../assets/icons/constrain/distance_x.svg")) } - pub fn vertical() -> ToolDef { command("DCVERTICAL", "Vertical", include_bytes!("../../../assets/icons/constrain/distance_y.svg")) } - pub fn aligned() -> ToolDef { command("DCALIGNED", "Aligned", include_bytes!("../../../assets/icons/dim_aligned.svg")) } - pub fn angular() -> ToolDef { command("DCANGULAR", "Angular", include_bytes!("../../../assets/icons/dim_angular.svg")) } - pub fn radius() -> ToolDef { command("DCRADIUS", "Radius", include_bytes!("../../../assets/icons/dim_radius.svg")) } - pub fn diameter() -> ToolDef { command("DCDIAMETER", "Diameter", include_bytes!("../../../assets/icons/dim_diameter.svg")) } - pub fn convert() -> ToolDef { command("DCCONVERT", "Convert", include_bytes!("../../../assets/icons/constrain/convert.svg")) } + pub fn linear() -> ToolDef { + command( + "DCLINEAR", + "Linear", + include_bytes!("../../../assets/icons/dim_linear.svg"), + ) + } + pub fn horizontal() -> ToolDef { + command( + "DCHORIZONTAL", + "Horizontal", + include_bytes!("../../../assets/icons/constrain/distance_x.svg"), + ) + } + pub fn vertical() -> ToolDef { + command( + "DCVERTICAL", + "Vertical", + include_bytes!("../../../assets/icons/constrain/distance_y.svg"), + ) + } + pub fn aligned() -> ToolDef { + command( + "DCALIGNED", + "Aligned", + include_bytes!("../../../assets/icons/dim_aligned.svg"), + ) + } + pub fn angular() -> ToolDef { + command( + "DCANGULAR", + "Angular", + include_bytes!("../../../assets/icons/dim_angular.svg"), + ) + } + pub fn radius() -> ToolDef { + command( + "DCRADIUS", + "Radius", + include_bytes!("../../../assets/icons/dim_radius.svg"), + ) + } + pub fn diameter() -> ToolDef { + command( + "DCDIAMETER", + "Diameter", + include_bytes!("../../../assets/icons/dim_diameter.svg"), + ) + } + pub fn convert() -> ToolDef { + command( + "DCCONVERT", + "Convert", + include_bytes!("../../../assets/icons/constrain/convert.svg"), + ) + } } pub mod angle_tool { @@ -83,9 +136,7 @@ pub mod angle_tool { ToolDef { id: "ACONSTRAINT", label: "Angle", - icon: IconKind::Svg(include_bytes!( - "../../../assets/icons/constrain/angle.svg" - )), + icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/angle.svg")), event: ModuleEvent::Command("ACONSTRAINT".to_string()), } } @@ -104,14 +155,10 @@ pub enum DistanceMode { X, Y, Diameter, - /// Arc length — only offered when `is_arc` (not a plain Circle, which - /// has no "arc length" distinct from its circumference). - ArcLength, } /// Constrains a single line's length (or its X/Y-only component) between -/// its two endpoints, or a circle/arc's radius (or diameter, or — for an -/// arc specifically — its swept arc length), to a typed target value. +/// its two endpoints, or a circle/arc's radius or diameter, to a typed target value. pub struct DistanceConstraintCommand { command_name: &'static str, handle: Handle, @@ -119,16 +166,10 @@ pub struct DistanceConstraintCommand { /// (Distance/DistanceX/DistanceY) — decided once at construction from /// the entity's type. is_circle: bool, - /// Whether `handle` is specifically an Arc (as opposed to a full - /// Circle) — gates the `[Arclength]` prompt option. - is_arc: bool, mode: DistanceMode, default_value: f64, default_x: f64, default_y: f64, - /// The entity's current arc length, for `ArcLength` mode's `` - /// — meaningless (left at 0) unless `is_arc`. - default_arc_length: f64, /// Snapshot of `Scene::named_parameters`' names at construction time — /// see `parse_driving_value`'s doc comment for why a snapshot. known_param_names: Vec, @@ -147,17 +188,16 @@ impl DistanceConstraintCommand { requested_mode: DistanceMode, ) -> Option { let entity = scene.document.get_entity(handle)?; - let (is_circle, is_arc, default_value, default_x, default_y, default_arc_length) = - match entity { - EntityType::Line(l) => { - let dx = l.end.x - l.start.x; - let dy = l.end.y - l.start.y; - (false, false, (dx * dx + dy * dy).sqrt(), dx, dy, 0.0) - } - EntityType::Circle(c) => (true, false, c.radius, 0.0, 0.0, 0.0), - EntityType::Arc(a) => (true, true, a.radius, 0.0, 0.0, a.arc_length()), - _ => return None, - }; + let (is_circle, default_value, default_x, default_y) = match entity { + EntityType::Line(l) => { + let dx = l.end.x - l.start.x; + let dy = l.end.y - l.start.y; + (false, (dx * dx + dy * dy).sqrt(), dx, dy) + } + EntityType::Circle(c) => (true, c.radius, 0.0, 0.0), + EntityType::Arc(a) => (true, a.radius, 0.0, 0.0), + _ => return None, + }; let known_param_names = scene .named_parameters() .iter() @@ -165,7 +205,11 @@ impl DistanceConstraintCommand { .collect(); let mode = match requested_mode { DistanceMode::Linear if !is_circle => { - if default_x.abs() >= default_y.abs() { DistanceMode::X } else { DistanceMode::Y } + if default_x.abs() >= default_y.abs() { + DistanceMode::X + } else { + DistanceMode::Y + } } DistanceMode::Aligned if !is_circle => DistanceMode::Aligned, DistanceMode::Radius if is_circle => DistanceMode::Radius, @@ -178,12 +222,10 @@ impl DistanceConstraintCommand { command_name, handle, is_circle, - is_arc, mode, default_value, default_x, default_y, - default_arc_length, known_param_names, }) } @@ -198,46 +240,41 @@ impl DistanceConstraintCommand { let (kind, refs, label) = match (self.is_circle, self.mode) { (true, DistanceMode::Diameter) => ( ConstraintKind::Diameter, - vec![SketchRef::whole(self.handle)], + vec![ParametricRef::whole(self.handle)], "Diameter constraint", ), - (true, DistanceMode::ArcLength) if self.is_arc => ( - ConstraintKind::ArcLength, - vec![SketchRef::whole(self.handle)], - "Arc length constraint", - ), (true, DistanceMode::Auto | DistanceMode::Radius) => ( ConstraintKind::Radius, - vec![SketchRef::whole(self.handle)], + vec![ParametricRef::whole(self.handle)], "Radius constraint", ), (false, DistanceMode::X) => ( ConstraintKind::DistanceX, vec![ - SketchRef::point(self.handle, 0), - SketchRef::point(self.handle, 1), + ParametricRef::point(self.handle, 0), + ParametricRef::point(self.handle, 1), ], "DistanceX constraint", ), (false, DistanceMode::Y) => ( ConstraintKind::DistanceY, vec![ - SketchRef::point(self.handle, 0), - SketchRef::point(self.handle, 1), + ParametricRef::point(self.handle, 0), + ParametricRef::point(self.handle, 1), ], "DistanceY constraint", ), (false, DistanceMode::Auto | DistanceMode::Aligned | DistanceMode::Linear) => ( ConstraintKind::Distance, vec![ - SketchRef::point(self.handle, 0), - SketchRef::point(self.handle, 1), + ParametricRef::point(self.handle, 0), + ParametricRef::point(self.handle, 1), ], "Distance constraint", ), _ => return None, }; - Some(CmdResult::AddSketchConstraint { + Some(CmdResult::AddParametricConstraint { kind, refs, driving_param: Some(target), @@ -257,16 +294,6 @@ impl CadCommand for DistanceConstraintCommand { "Specify diameter <{:.4}> or [Radius]: ", self.default_value * 2.0 ), - (true, DistanceMode::ArcLength) if self.is_arc => { - format!( - "Specify arc length <{:.4}> or [Radius]: ", - self.default_arc_length - ) - } - (true, DistanceMode::Auto | DistanceMode::Radius) if self.is_arc => format!( - "Specify distance <{:.4}> or [Diameter/Arclength]: ", - self.default_value - ), (true, DistanceMode::Auto | DistanceMode::Radius) => format!( "Specify distance <{:.4}> or [Diameter]: ", self.default_value @@ -292,10 +319,12 @@ impl CadCommand for DistanceConstraintCommand { fn on_enter(&mut self) -> CmdResult { let default = match self.mode { DistanceMode::Diameter => self.default_value * 2.0, - DistanceMode::ArcLength => self.default_arc_length, DistanceMode::X => self.default_x, DistanceMode::Y => self.default_y, - DistanceMode::Auto | DistanceMode::Aligned | DistanceMode::Linear | DistanceMode::Radius => self.default_value, + DistanceMode::Auto + | DistanceMode::Aligned + | DistanceMode::Linear + | DistanceMode::Radius => self.default_value, }; self.build(DrivingValue::Literal(default)) .unwrap_or(CmdResult::Cancel) @@ -314,10 +343,6 @@ impl CadCommand for DistanceConstraintCommand { self.mode = DistanceMode::Diameter; return Some(CmdResult::NeedPoint); } - (true, "A" | "ARCLENGTH") if self.is_arc => { - self.mode = DistanceMode::ArcLength; - return Some(CmdResult::NeedPoint); - } (true, "R" | "RADIUS") => { self.mode = DistanceMode::Radius; return Some(CmdResult::NeedPoint); @@ -390,11 +415,11 @@ impl AngleConstraintCommand { if matches!(&target, DrivingValue::Literal(value) if !value.is_finite()) { return None; } - Some(CmdResult::AddSketchConstraint { + Some(CmdResult::AddParametricConstraint { kind: ConstraintKind::Angle, refs: vec![ - SketchRef::whole(self.fixed_handle), - SketchRef::whole(self.moving_handle), + ParametricRef::whole(self.fixed_handle), + ParametricRef::whole(self.moving_handle), ], driving_param: Some(target), label: "Angle constraint", @@ -437,8 +462,15 @@ impl CadCommand for AngleConstraintCommand { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &[ - "DCONSTRAINT", "ACONSTRAINT", "DCLINEAR", "DCHORIZONTAL", "DCVERTICAL", - "DCALIGNED", "DCANGULAR", "DCRADIUS", "DCDIAMETER", + "DCONSTRAINT", + "ACONSTRAINT", + "DCLINEAR", + "DCHORIZONTAL", + "DCVERTICAL", + "DCALIGNED", + "DCANGULAR", + "DCRADIUS", + "DCDIAMETER", ] }); @@ -509,17 +541,6 @@ mod tests { )) } - fn add_arc(scene: &mut Scene) -> Handle { - scene.add_entity(EntityType::Arc( - acadrust::entities::Arc::from_center_radius_angles( - acadrust::types::Vector3::new(0.0, 0.0, 0.0), - 3.0, - 0.0, - std::f64::consts::FRAC_PI_2, - ), - )) - } - #[test] fn x_keyword_switches_mode_and_then_builds_a_distance_x_constraint() { let mut scene = Scene::new(); @@ -537,7 +558,7 @@ mod tests { ); match cmd.on_text_input("10") { - Some(CmdResult::AddSketchConstraint { + Some(CmdResult::AddParametricConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. @@ -545,7 +566,7 @@ mod tests { assert_eq!(kind, ConstraintKind::DistanceX); assert_eq!(v, 10.0); } - _ => panic!("expected an AddSketchConstraint{{DistanceX}}, got a different result"), + _ => panic!("expected an AddParametricConstraint{{DistanceX}}, got a different result"), } } @@ -558,7 +579,7 @@ mod tests { assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint))); assert!(matches!( cmd.on_text_input("-3"), - Some(CmdResult::AddSketchConstraint { + Some(CmdResult::AddParametricConstraint { kind: ConstraintKind::DistanceX, driving_param: Some(DrivingValue::Literal(-3.0)), .. @@ -576,7 +597,7 @@ mod tests { assert!(cmd.prompt().contains("<8.0000>")); assert!(matches!( cmd.on_enter(), - CmdResult::AddSketchConstraint { + CmdResult::AddParametricConstraint { kind: ConstraintKind::DistanceY, driving_param: Some(DrivingValue::Literal(8.0)), .. @@ -609,7 +630,7 @@ mod tests { ); match cmd.on_text_input("16") { - Some(CmdResult::AddSketchConstraint { + Some(CmdResult::AddParametricConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. @@ -617,7 +638,7 @@ mod tests { assert_eq!(kind, ConstraintKind::Diameter); assert_eq!(v, 16.0); } - _ => panic!("expected an AddSketchConstraint{{Diameter}}"), + _ => panic!("expected an AddParametricConstraint{{Diameter}}"), } } @@ -629,70 +650,10 @@ mod tests { DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); match cmd.on_text_input("5") { - Some(CmdResult::AddSketchConstraint { kind, .. }) => { + Some(CmdResult::AddParametricConstraint { kind, .. }) => { assert_eq!(kind, ConstraintKind::Radius) } - _ => panic!("expected an AddSketchConstraint{{Radius}}"), + _ => panic!("expected an AddParametricConstraint{{Radius}}"), } } - - #[test] - fn a_keyword_switches_an_arcs_command_to_arc_length_mode() { - let mut scene = Scene::new(); - let arc = add_arc(&mut scene); - let mut cmd = - DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT"); - - assert!(matches!(cmd.on_text_input("A"), Some(CmdResult::NeedPoint))); - assert!( - cmd.prompt().contains("arc length"), - "prompt should reflect ArcLength mode: {}", - cmd.prompt() - ); - - match cmd.on_text_input("8") { - Some(CmdResult::AddSketchConstraint { - kind, - driving_param: Some(DrivingValue::Literal(v)), - .. - }) => { - assert_eq!(kind, ConstraintKind::ArcLength); - assert_eq!(v, 8.0); - } - _ => panic!("expected an AddSketchConstraint{{ArcLength}}"), - } - } - - #[test] - fn arc_length_keyword_is_not_offered_for_a_plain_circle() { - let mut scene = Scene::new(); - let circle = add_circle(&mut scene); - let mut cmd = - DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); - - assert!( - !cmd.prompt().contains("Arclength"), - "a plain circle has no arc length option: {}", - cmd.prompt() - ); - // "A" isn't a recognized keyword here and doesn't parse as a - // number or a named parameter either, so it's rejected outright. - assert!(cmd.on_text_input("A").is_none()); - } - - #[test] - fn arc_length_default_is_the_arcs_current_swept_length() { - let mut scene = Scene::new(); - let arc = add_arc(&mut scene); // radius 3, 90° sweep -> length = 3 * pi/2 - let mut cmd = - DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT"); - cmd.on_text_input("A"); - - let expected = 3.0 * std::f64::consts::FRAC_PI_2; - assert!( - cmd.prompt().contains(&format!("{expected:.4}")), - "prompt should default to the arc's current length: {}", - cmd.prompt() - ); - } } diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 8069e6eea..e66fef3b3 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -1,13 +1,13 @@ use acadrust::entities::Dimension; use acadrust::objects::{ - AssocDimensionAssociation, AssocDimensionReference, AssociativeData, - AssociativeObject, ObjectType, + AssocDimensionAssociation, AssocDimensionReference, AssociativeData, AssociativeObject, + ObjectType, }; use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; use cadkernel::geom2d::{ - angle_within_arc, arc as tessellate_arc, arc_span, closest_point, Arc as KernelArc, - BulgeArc, Circle as KernelCircle, Curve as KernelCurve, DEFAULT_SEGMENTS_PER_RADIAN, + angle_within_arc, arc as tessellate_arc, arc_span, closest_point, Arc as KernelArc, BulgeArc, + Circle as KernelCircle, Curve as KernelCurve, DEFAULT_SEGMENTS_PER_RADIAN, }; use cadkernel::space::Plane; use std::f64::consts::TAU; @@ -25,8 +25,7 @@ pub(crate) fn polyline_arc_point_marker(segment: i32) -> i32 { } fn polyline_arc_segment_from_point_marker(marker: i32) -> Option { - (marker <= POLYLINE_ARC_POINT_MARKER_BASE) - .then_some(POLYLINE_ARC_POINT_MARKER_BASE - marker) + (marker <= POLYLINE_ARC_POINT_MARKER_BASE).then_some(POLYLINE_ARC_POINT_MARKER_BASE - marker) } #[derive(Clone, Copy, Debug)] @@ -158,11 +157,13 @@ pub(crate) fn radial_source_at( entity: &EntityType, point: Vector3, ) -> Option { - radial_candidates(entity).into_iter().min_by(|first, second| { - first - .distance_squared_to(dpoint(point)) - .total_cmp(&second.distance_squared_to(dpoint(point))) - }) + radial_candidates(entity) + .into_iter() + .min_by(|first, second| { + first + .distance_squared_to(dpoint(point)) + .total_cmp(&second.distance_squared_to(dpoint(point))) + }) } fn radial_source_for_marker(entity: &EntityType, marker: i32) -> Option { @@ -177,14 +178,16 @@ fn radial_source_matching( radius: f64, chord: Vector3, ) -> Option { - radial_candidates(entity).into_iter().min_by(|first, second| { - let score = |candidate: &RadialSourceGeometry| { - point_distance_squared(candidate.center_world(), center) - + (candidate.radius - radius).powi(2) - + candidate.distance_squared_to(dpoint(chord)) * 1e-6 - }; - score(first).total_cmp(&score(second)) - }) + radial_candidates(entity) + .into_iter() + .min_by(|first, second| { + let score = |candidate: &RadialSourceGeometry| { + point_distance_squared(candidate.center_world(), center) + + (candidate.radius - radius).powi(2) + + candidate.distance_squared_to(dpoint(chord)) * 1e-6 + }; + score(first).total_cmp(&score(second)) + }) } fn point_distance_squared(first: Vector3, second: Vector3) -> f64 { @@ -235,9 +238,10 @@ fn polyline_arc_center(entity: &EntityType, segment: usize) -> Option { EntityType::LwPolyline(polyline) => { let count = polyline.vertices.len(); let first = *polyline.vertices.get(segment)?; - let second = *polyline - .vertices - .get(next_segment_index(count, polyline.is_closed, segment)?)?; + let second = + *polyline + .vertices + .get(next_segment_index(count, polyline.is_closed, segment)?)?; bulge_center_world( [first.location.x, first.location.y], [second.location.x, second.location.y], @@ -249,9 +253,10 @@ fn polyline_arc_center(entity: &EntityType, segment: usize) -> Option { EntityType::Polyline2D(polyline) => { let count = polyline.vertices.len(); let first = polyline.vertices.get(segment)?; - let second = polyline - .vertices - .get(next_segment_index(count, polyline.is_closed(), segment)?)?; + let second = + polyline + .vertices + .get(next_segment_index(count, polyline.is_closed(), segment)?)?; bulge_center_world( [first.location.x, first.location.y], [second.location.x, second.location.y], @@ -267,20 +272,24 @@ fn polyline_arc_center(entity: &EntityType, segment: usize) -> Option { /// Ordered, named points for an entity (line start/end, polyline vertices, /// ...), indexed by the same non-negative GsMarker convention /// `AssocDimensionReference::main_gs_marker` uses. Promoted to -/// `pub(crate)` so `sketch::constraint_set` can address constraint +/// `pub(crate)` so the parametric solver can address constraint /// endpoints the same way associative dimensions already address theirs, /// rather than inventing a second sub-element scheme. pub(crate) fn source_points(entity: &EntityType) -> Vec { match entity { EntityType::Line(line) => vec![line.start, line.end], + EntityType::Ray(ray) => vec![ray.base_point], EntityType::Arc(arc) => vec![arc.start_point_wcs(), arc.end_point_wcs()], EntityType::Circle(_) => Vec::new(), EntityType::Spline(spline) => crate::entities::spline::nurbs3(spline) .map(|curve| { let (start, end) = curve.domain(); - [curve.point_at_knot(start), curve.point_at_knot(end)].into_iter() - .map(|point| Vector3::new(point[0], point[1], point[2])).collect() - }).unwrap_or_default(), + [curve.point_at_knot(start), curve.point_at_knot(end)] + .into_iter() + .map(|point| Vector3::new(point[0], point[1], point[2])) + .collect() + }) + .unwrap_or_default(), EntityType::LwPolyline(polyline) => polyline .vertices .iter() @@ -345,8 +354,7 @@ fn source_marker(entity: &EntityType, point: Vector3) -> Option { .into_iter() .enumerate() .min_by(|(_, first), (_, second)| { - point_distance_squared(*first, point) - .total_cmp(&point_distance_squared(*second, point)) + point_distance_squared(*first, point).total_cmp(&point_distance_squared(*second, point)) }) .map(|(index, _)| index as i32) } @@ -360,9 +368,7 @@ fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Opti let angle = radial.start_angle + sweep * reference.osnap_distance.clamp(0.0, 1.0); return Some(radial.point_at_angle(angle)); } - if let Some(segment) = - polyline_arc_segment_from_point_marker(reference.main_gs_marker) - { + if let Some(segment) = polyline_arc_segment_from_point_marker(reference.main_gs_marker) { let radial = radial_source_for_marker(entity, segment)?; let sweep = positive_sweep(radial.start_angle, radial.end_angle); let angle = radial.start_angle + sweep * reference.osnap_distance.clamp(0.0, 1.0); @@ -381,9 +387,7 @@ fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Opti } if reference.main_gs_marker == -2 { return match entity { - EntityType::Circle(circle) => { - Some(circle.point_at_angle_wcs(reference.osnap_distance)) - } + EntityType::Circle(circle) => Some(circle.point_at_angle_wcs(reference.osnap_distance)), EntityType::Arc(arc) => Some(arc.point_at_angle_wcs(reference.osnap_distance)), _ => None, }; @@ -399,14 +403,17 @@ fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Opti ); let curve = circle_curve(circle); let stored_parameter = curve.parameter_at([stored.0, stored.1]) * TAU; - let parameter = if reference.osnap_distance.abs() > 1e-12 - || stored_parameter.abs() <= 1e-12 + let parameter = if reference.osnap_distance.abs() > 1e-12 || stored_parameter.abs() <= 1e-12 { reference.osnap_distance } else { stored_parameter }; - let parameter = if parameter.is_finite() { parameter } else { 0.0 }; + let parameter = if parameter.is_finite() { + parameter + } else { + 0.0 + }; let point = curve.point_at(parameter / TAU); return Some(ocs_point( point[0], @@ -494,27 +501,13 @@ fn signed_angle_delta(value: f64) -> f64 { (value + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI } -fn angle_about_plane( - plane: Plane, - center: Vector3, - point: Vector3, -) -> f64 { - let delta = [ - point.x - center.x, - point.y - center.y, - point.z - center.z, - ]; - let dot = |axis: [f64; 3]| { - delta[0] * axis[0] + delta[1] * axis[1] + delta[2] * axis[2] - }; +fn angle_about_plane(plane: Plane, center: Vector3, point: Vector3) -> f64 { + let delta = [point.x - center.x, point.y - center.y, point.z - center.z]; + let dot = |axis: [f64; 3]| delta[0] * axis[0] + delta[1] * axis[1] + delta[2] * axis[2]; dot(plane.y_axis).atan2(dot(plane.x_axis)) } -fn point_on_radial_circle( - radial: RadialSourceGeometry, - radius: f64, - angle: f64, -) -> Vector3 { +fn point_on_radial_circle(radial: RadialSourceGeometry, radius: f64, angle: f64) -> Vector3 { vector3(radial.plane.point_at([ radial.center[0] + radius * angle.cos(), radial.center[1] + radius * angle.sin(), @@ -522,9 +515,7 @@ fn point_on_radial_circle( } fn plane_from_normal(origin: Vector3, normal: Vector3) -> Plane { - let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes(( - normal.x, normal.y, normal.z, - )); + let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes((normal.x, normal.y, normal.z)); Plane::from_axes( [origin.x, origin.y, origin.z], [x_axis.0, x_axis.1, x_axis.2], @@ -574,35 +565,52 @@ pub(crate) fn constraint_from_associative_dimension( document: &acadrust::CadDocument, handle: Handle, ) -> Option<( - super::sketch_constraints::ConstraintKind, - Vec, + super::parametric_constraints::ConstraintKind, + Vec, super::named_parameters::DrivingValue, )> { use super::named_parameters::DrivingValue; - use super::sketch_constraints::{ConstraintKind, SketchRef}; + use super::parametric_constraints::{ConstraintKind, ParametricRef}; - let EntityType::Dimension(dimension) = document.get_entity(handle)? else { return None }; + let EntityType::Dimension(dimension) = document.get_entity(handle)? else { + return None; + }; let association = document.objects.values().find_map(|object| { - let ObjectType::Associative(object) = object else { return None }; - let AssociativeData::DimensionAssociation(association) = &object.data else { return None }; + let ObjectType::Associative(object) = object else { + return None; + }; + let AssociativeData::DimensionAssociation(association) = &object.data else { + return None; + }; (association.dimension == handle && association.associativity != 0).then_some(association) })?; let point = |index: usize| { let reference = association.references.get(index)?.first()?; - Some(SketchRef::point(*reference.xrefs.first()?, reference.main_gs_marker)) + Some(ParametricRef::point( + *reference.xrefs.first()?, + reference.main_gs_marker, + )) }; let whole = |index: usize| { let reference = association.references.get(index)?.first()?; - Some(SketchRef::whole(*reference.xrefs.first()?)) + Some(ParametricRef::whole(*reference.xrefs.first()?)) }; let measurement = DrivingValue::Literal(dimension.measurement()); match dimension { - Dimension::Aligned(_) => Some((ConstraintKind::Distance, vec![point(0)?, point(1)?], measurement)), + Dimension::Aligned(_) => Some(( + ConstraintKind::Distance, + vec![point(0)?, point(1)?], + measurement, + )), Dimension::Linear(linear) => { let direction = [linear.rotation.cos().abs(), linear.rotation.sin().abs()]; - let kind = if direction[1] <= 1e-9 { ConstraintKind::DistanceX } - else if direction[0] <= 1e-9 { ConstraintKind::DistanceY } - else { return None }; + let kind = if direction[1] <= 1e-9 { + ConstraintKind::DistanceX + } else if direction[0] <= 1e-9 { + ConstraintKind::DistanceY + } else { + return None; + }; Some((kind, vec![point(0)?, point(1)?], measurement)) } Dimension::Radius(_) | Dimension::LargeRadial(_) => { @@ -610,16 +618,24 @@ pub(crate) fn constraint_from_associative_dimension( } Dimension::Diameter(_) => Some((ConstraintKind::Diameter, vec![whole(0)?], measurement)), Dimension::Angular2Ln(_) => { - let mut sources: Vec = association.references.iter().flatten() - .filter_map(|reference| reference.xrefs.first().copied()).collect(); + let mut sources: Vec = association + .references + .iter() + .flatten() + .filter_map(|reference| reference.xrefs.first().copied()) + .collect(); sources.sort_unstable(); sources.dedup(); - let [first, second] = sources.as_slice() else { return None }; - Some((ConstraintKind::Angle, - vec![SketchRef::whole(*first), SketchRef::whole(*second)], measurement)) + let [first, second] = sources.as_slice() else { + return None; + }; + Some(( + ConstraintKind::Angle, + vec![ParametricRef::whole(*first), ParametricRef::whole(*second)], + measurement, + )) } - Dimension::Arc(_) => Some((ConstraintKind::ArcLength, vec![whole(0)?], measurement)), - Dimension::Angular3Pt(_) | Dimension::Ordinate(_) => None, + Dimension::Arc(_) | Dimension::Angular3Pt(_) | Dimension::Ordinate(_) => None, } } @@ -716,8 +732,7 @@ impl Scene { dimension: Handle, sources: Vec>, ) { - let Some(EntityType::Dimension(dimension_entity)) = - self.document.get_entity(dimension) + let Some(EntityType::Dimension(dimension_entity)) = self.document.get_entity(dimension) else { return; }; @@ -803,22 +818,20 @@ impl Scene { return; } - let reference = |source: Handle, - marker: i32, - parameter: f64, - osnap_type: u8, - point: Vector3| AssocDimensionReference { - class_name: "AcDbOsnapPointRef".to_string(), - osnap_type, - xrefs: vec![source], - main_subent_type: 1, - main_gs_marker: marker, - osnap_distance: parameter, - osnap_point: point, - ..AssocDimensionReference::default() - }; - let mut references: [Vec; 4] = - std::array::from_fn(|_| Vec::new()); + let reference = + |source: Handle, marker: i32, parameter: f64, osnap_type: u8, point: Vector3| { + AssocDimensionReference { + class_name: "AcDbOsnapPointRef".to_string(), + osnap_type, + xrefs: vec![source], + main_subent_type: 1, + main_gs_marker: marker, + osnap_distance: parameter, + osnap_point: point, + ..AssocDimensionReference::default() + } + }; + let mut references: [Vec; 4] = std::array::from_fn(|_| Vec::new()); let mut associativity = 0; for (index, resolved) in resolved.into_iter().enumerate() { if index >= references.len() { @@ -873,12 +886,8 @@ impl Scene { } } - pub(crate) fn infer_dimension_sources( - &self, - dimension: Handle, - ) -> Vec> { - let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) - else { + pub(crate) fn infer_dimension_sources(&self, dimension: Handle) -> Vec> { + let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) else { return Vec::new(); }; let radial_data = match entity { @@ -906,18 +915,11 @@ impl Scene { .entities() .filter(|candidate| candidate.common().handle != dimension) .filter_map(|candidate| { - let radial = radial_source_matching( - candidate, - center, - radius, - chord, - )?; + let radial = radial_source_matching(candidate, center, radius, chord)?; let center_error = point_distance_squared(radial.center_world(), center); let radius_error = (radial.radius - radius).powi(2); - (center_error + radius_error <= tolerance * tolerance).then_some(( - center_error + radius_error, - candidate.common().handle, - )) + (center_error + radius_error <= tolerance * tolerance) + .then_some((center_error + radius_error, candidate.common().handle)) }) .min_by(|first, second| first.0.total_cmp(&second.0)) .map(|(_, handle)| handle); @@ -946,8 +948,7 @@ impl Scene { &mut self, changes: &[(Handle, ChangeKind)], ) -> Vec<(Handle, ChangeKind)> { - let changed: rustc_hash::FxHashSet<_> = - changes.iter().map(|(handle, _)| *handle).collect(); + let changed: rustc_hash::FxHashSet<_> = changes.iter().map(|(handle, _)| *handle).collect(); if changed.is_empty() { return Vec::new(); } @@ -966,7 +967,12 @@ impl Scene { .references .iter() .flatten() - .any(|reference| reference.xrefs.iter().any(|handle| changed.contains(handle))) + .any(|reference| { + reference + .xrefs + .iter() + .any(|handle| changed.contains(handle)) + }) .then_some(association.clone()) }) .collect(); @@ -989,9 +995,7 @@ impl Scene { let entity = self.document.get_entity(source)?; let segment = match reference.main_gs_marker { -3 => 0, - POLYLINE_ARC_CENTER_MARKER => { - reference.osnap_distance.round().max(0.0) as i32 - } + POLYLINE_ARC_CENTER_MARKER => reference.osnap_distance.round().max(0.0) as i32, _ => return None, }; radial_source_for_marker(entity, segment) @@ -1144,10 +1148,7 @@ impl Scene { let old_dim_radius = old_center.distance(&old_definition); let radial_offset = old_dim_radius - old_source_radius; let old_mid = arc.arc_start_parameter - + positive_sweep( - arc.arc_start_parameter, - arc.arc_end_parameter, - ) * 0.5; + + positive_sweep(arc.arc_start_parameter, arc.arc_end_parameter) * 0.5; let old_definition_angle = angle_about_plane(old_plane, old_center, old_definition); let definition_angle_offset = @@ -1208,8 +1209,7 @@ impl Scene { arc.base.insertion_point = definition; } if arc.has_leader { - arc.first_leader_point = - point_on_radial_circle(radial, dim_radius, middle); + arc.first_leader_point = point_on_radial_circle(radial, dim_radius, middle); arc.second_leader_point = second_leader; } arc.base.actual_measurement = arc.measurement(); @@ -1228,7 +1228,9 @@ impl Scene { fn linear_measurement(linear: &acadrust::entities::DimensionLinear) -> f64 { let plane = plane_from_normal(linear.first_point, linear.base.normal); - let first = plane.project(dpoint(linear.first_point)).unwrap_or([0.0; 2]); + let first = plane + .project(dpoint(linear.first_point)) + .unwrap_or([0.0; 2]); let second = plane.project(dpoint(linear.second_point)).unwrap_or(first); let axis = [linear.rotation.cos(), linear.rotation.sin()]; ((second[0] - first[0]) * axis[0] + (second[1] - first[1]) * axis[1]).abs() diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index 11e0a7da7..df6440aa0 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -1,5 +1,4 @@ -//! Maps sketch constraints to the drawing format's native associative object graph. -//! The application record remains authoritative when both representations exist. +//! Maps parametric constraints to the drawing format's associative object graph. use acadrust::objects::{ Assoc2dConstraintGroup, AssocAction, AssocActionDependency, AssocConstraintNode, @@ -12,8 +11,9 @@ use acadrust::{CadDocument, EntityType}; use rustc_hash::FxHashMap; use super::named_parameters::{DrivingValue, ParameterTable}; -use super::sketch_constraints::{ - ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef, SketchScope, +use super::parametric_constraints::{ + distance_direction_type, ConstraintKind, NativeConstraintOrigin, ParametricConstraint, + ParametricConstraintSet, ParametricRef, ParametricScope, }; use super::Scene; @@ -28,14 +28,15 @@ const FIRST_NODE_ID: i32 = 1; mod implicit_point_type { pub const START: u8 = 0; pub const END: u8 = 1; - #[allow(dead_code)] // reserved for SketchRef marker -2, not wired up yet + #[allow(dead_code)] // reserved for ParametricRef marker -2, not wired up yet pub const MID: u8 = 2; pub const CENTER: u8 = 3; + pub const DEFINE: u8 = 4; } /// One entity's constraint-group presence: its geometry node's id, plus /// whichever `ImplicitPoint` nodes have been created for it so far, keyed -/// by our own `SketchRef::marker` convention so a second constraint +/// by our own `ParametricRef::marker` convention so a second constraint /// referencing the same point reuses the same node instead of duplicating it. #[derive(Default)] struct EntityNodes { @@ -88,6 +89,7 @@ impl<'a> GroupBuilder<'a> { | AssocConstraintNodeData::Arc { .. } | AssocConstraintNodeData::Ellipse { .. } | AssocConstraintNodeData::BoundedEllipse { .. } + | AssocConstraintNodeData::Spline { .. } )); self.nodes.push(AssocConstraintNode { node_id, @@ -195,6 +197,33 @@ impl<'a> GroupBuilder<'a> { end_point: l.end, }; } + EntityType::Ray(ray) => { + self.push_node( + node_id, + "AcConstrainedBoundedLine", + AssocConstraintNodeData::BoundedLine { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: ray.base_point, + direction: ray.direction, + is_ray: true, + start_point: ray.base_point, + end_point: ray.base_point + ray.direction, + }, + ); + } + EntityType::XLine(line) => { + self.push_node( + node_id, + "AcConstrainedLine", + AssocConstraintNodeData::Line { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: line.base_point, + direction: line.direction, + }, + ); + } EntityType::Circle(c) => { let (axis_x, _axis_y) = crate::scene::view::transform::ocs_axes((c.normal.x, c.normal.y, c.normal.z)); @@ -286,6 +315,78 @@ impl<'a> GroupBuilder<'a> { }; self.push_node(node_id, class_name, data); } + EntityType::Spline(spline) => { + let curve = crate::entities::spline::nurbs3(spline)?; + let weights = curve.weights(); + let rational = spline.flags.rational + || weights.iter().any(|weight| (*weight - 1.0).abs() > 1.0e-12); + let control_points: Vec<_> = curve + .control_points() + .iter() + .map(|point| Vector3::new(point[0], point[1], point[2])) + .collect(); + let mut implicit_point_ids = Vec::with_capacity(control_points.len()); + self.push_node( + node_id, + "AcConstrainedSpline", + AssocConstraintNodeData::Spline { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + rational, + periodic: spline.flags.periodic, + degree: curve.degree() as i32, + knot_tolerance: spline.knot_tolerance.max(1.0e-10), + knot_physical_length: curve.knots().len() as i32, + knot_grow_length: 8, + knots: curve.knots().to_vec(), + weight_physical_length: if rational { weights.len() as i32 } else { 0 }, + weight_grow_length: 8, + weights: if rational { + weights.to_vec() + } else { + Vec::new() + }, + control_point_physical_length: control_points.len() as i32, + control_point_grow_length: 8, + control_points, + implicit_point_ids: Vec::new(), + }, + ); + for point_index in 0..curve.control_points().len() { + let point_id = self.alloc_node_id(); + self.push_node( + point_id, + "AcConstrainedImplicitPoint", + AssocConstraintNodeData::ImplicitPoint { + geometry_dependency: Handle::NULL, + geometry_node_id: point_id, + point: None, + point_type: implicit_point_type::DEFINE, + point_index: point_index as i32, + curve_id: node_id, + }, + ); + implicit_point_ids.push(point_id); + } + if let Some(AssocConstraintNode { + data: + AssocConstraintNodeData::Spline { + implicit_point_ids: ids, + .. + }, + .. + }) = self.nodes.iter_mut().find(|node| node.node_id == node_id) + { + *ids = implicit_point_ids.clone(); + } + let points = &mut self.entities.entry(handle).or_default().points; + if let Some(first) = implicit_point_ids.first() { + points.insert(0, *first); + } + if let Some(last) = implicit_point_ids.last() { + points.insert(1, *last); + } + } _ => return None, } self.entities.entry(handle).or_default().geometry_node_id = node_id; @@ -305,11 +406,17 @@ impl<'a> GroupBuilder<'a> { { return Some(*existing); } + let segment_midpoint = ParametricRef::point(handle, marker).segment_midpoint_index(); let polyline = matches!( self.document.get_entity(handle), Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_)) ); - let (curve_id, point_type) = if polyline && marker >= 0 { + let (curve_id, point_type) = if let Some(segment) = segment_midpoint { + ( + self.segment_node(handle, segment)?, + implicit_point_type::MID, + ) + } else if polyline && marker >= 0 { let entity = self.document.get_entity(handle)?; let points = super::dimension_assoc::source_points(entity); let closed = match entity { @@ -329,9 +436,17 @@ impl<'a> GroupBuilder<'a> { (self.segment_node(handle, segment)?, point_type) } else { let curve_id = self.geometry_node(handle)?; + if let Some(existing) = self + .entities + .get(&handle) + .and_then(|entity| entity.points.get(&marker)) + { + return Some(*existing); + } let point_type = match marker { 0 => implicit_point_type::START, 1 => implicit_point_type::END, + -2 => implicit_point_type::MID, -3 => implicit_point_type::CENTER, _ => return None, }; @@ -350,22 +465,28 @@ impl<'a> GroupBuilder<'a> { curve_id, }, ); - let relation_id = self.alloc_node_id(); - self.push_node( - relation_id, - if point_type == implicit_point_type::CENTER { - "AcCenterPointConstraint" - } else { - "AcPointCurveConstraint" - }, - AssocConstraintNodeData::Geometrical { - owner_id: 0, - is_implied: false, - is_active: true, - }, - ); - self.connect(relation_id, node_id); - self.connect(relation_id, curve_id); + let mut relations = vec![if point_type == implicit_point_type::CENTER { + "AcCenterPointConstraint" + } else { + "AcPointCurveConstraint" + }]; + if point_type == implicit_point_type::MID { + relations.push("AcMidPointConstraint"); + } + for class_name in relations { + let relation_id = self.alloc_node_id(); + self.push_node( + relation_id, + class_name, + AssocConstraintNodeData::Geometrical { + owner_id: 0, + is_implied: true, + is_active: true, + }, + ); + self.connect(relation_id, node_id); + self.connect(relation_id, curve_id); + } self.entities .entry(handle) .or_default() @@ -374,9 +495,12 @@ impl<'a> GroupBuilder<'a> { Some(node_id) } - /// The node id `SketchRef` resolves to: a point node for a marked + /// The node id `ParametricRef` resolves to: a point node for a marked /// reference, the whole geometry node otherwise. - fn ref_node(&mut self, r: SketchRef) -> Option { + fn ref_node(&mut self, r: ParametricRef) -> Option { + if r.segment_midpoint_index().is_some() { + return self.point_node(r.entity, r.marker?); + } if let Some(segment) = r.segment_index() { return self.segment_node(r.entity, segment); } @@ -509,7 +633,6 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { ConstraintKind::Coincident => Some("AcPointCoincidenceConstraint"), ConstraintKind::Perpendicular => Some("AcPerpendicularConstraint"), ConstraintKind::Tangent => Some("AcTangentConstraint"), - ConstraintKind::Smooth => Some("AcG2SmoothConstraint"), // the native format's own `GeomConstraintType` enum (native SDK's // `AcGeomConstraint.h`) lists `kNormal` alongside `kPerpendicular` // as a distinct, real geometric-constraint kind. @@ -528,6 +651,7 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { ConstraintKind::EqualDistance => Some("AcEqualDistanceConstraint"), // `Equal` needs the resolved entity types, so the caller handles it. ConstraintKind::Equal + | ConstraintKind::Smooth | ConstraintKind::Horizontal | ConstraintKind::Vertical | ConstraintKind::Parallel @@ -536,15 +660,10 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { | ConstraintKind::Radius | ConstraintKind::Diameter | ConstraintKind::DistanceX - | ConstraintKind::DistanceY => None, - // No native DWG representation exists at all: neither - // `AcExplicitConstr.h` (Distance/Angle/RadiusDiameter — no - // ArcLength-shaped class) nor `acadrust`'s `AssocConstraintNodeData` - // has anything for it. `constraint_node` below falls through to - // its own `_ => None` for this kind, same "can't persist to DWG, - // XRecord-only" gap the dependency-chain DXF omission already - // documents. - ConstraintKind::ArcLength => None, + | ConstraintKind::DistanceY + | ConstraintKind::DistanceDirected + | ConstraintKind::Angle3Point + | ConstraintKind::RigidSet => None, } } @@ -555,10 +674,12 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { /// `constraint_node` below), not a distinct native class. const fn dimensional_class_name(kind: ConstraintKind) -> &'static str { match kind { - ConstraintKind::Distance | ConstraintKind::DistanceX | ConstraintKind::DistanceY => { - "AcDistanceConstraint" - } + ConstraintKind::Distance + | ConstraintKind::DistanceX + | ConstraintKind::DistanceY + | ConstraintKind::DistanceDirected => "AcDistanceConstraint", ConstraintKind::Angle => "AcAngleConstraint", + ConstraintKind::Angle3Point => "Ac3PointAngleConstraint", ConstraintKind::Radius | ConstraintKind::Diameter => "AcRadiusDiameterConstraint", _ => "", } @@ -571,29 +692,145 @@ const fn dimensional_class_name(kind: ConstraintKind) -> &'static str { /// split, `AssocGeomDependency`/`AssocValueDependency` handles need mutable /// access to the same document, so this has to be two passes). `None` if /// this constraint's refs don't resolve to nodes this pass supports -/// (mirrors `sketch_solve::build_constraint`'s own "skip, don't error" +/// (mirrors `parametric_solve::build_constraint`'s own "skip, don't error" /// contract for an unbuildable constraint). fn constraint_node( builder: &mut GroupBuilder, document: &CadDocument, - constraint: &SketchConstraint, + constraint: &ParametricConstraint, needs_value_dependency: &mut Vec<(i32, Option)>, ) -> Option { - let refs: &[SketchRef] = &constraint.refs; + let refs: &[ParametricRef] = &constraint.refs; + if constraint.kind == ConstraintKind::Smooth { + let [first, second] = refs else { return None }; + let endpoint_parameter = |reference: ParametricRef| { + let entity = document.get_entity(reference.entity)?; + let EntityType::Spline(spline) = entity else { + return Some(None); + }; + if spline.flags.closed || spline.flags.periodic { + return None; + } + let curve = crate::entities::spline::nurbs3(spline)?; + let value = match reference.marker { + Some(0) => curve.knots().get(curve.degree()).copied(), + Some(1) => curve + .knots() + .get(curve.knots().len().checked_sub(curve.degree() + 1)?) + .copied(), + _ => None, + }?; + Some(Some(value)) + }; + let first_parameter = endpoint_parameter(*first)?; + let second_parameter = endpoint_parameter(*second)?; + if first_parameter.is_none() && second_parameter.is_none() { + return None; + } + let first_curve = builder.geometry_node(first.entity)?; + let second_curve = builder.geometry_node(second.entity)?; + let first_point = builder.ref_node(*first)?; + let second_point = builder.ref_node(*second)?; + + let coincidence = builder.alloc_node_id(); + builder.push_node( + coincidence, + "AcPointCoincidenceConstraint", + AssocConstraintNodeData::Geometrical { + owner_id: 0, + is_implied: false, + is_active: constraint.enabled, + }, + ); + builder.connect(coincidence, first_point); + builder.connect(coincidence, second_point); + + let add_help_pair = |builder: &mut GroupBuilder, constraint_class| { + let mut add_help = |value: Option| { + value.map(|value| { + let node_id = builder.alloc_node_id(); + builder.push_node( + node_id, + "AcHelpParameter", + AssocConstraintNodeData::HelpParameter { + value, + reserved: false, + }, + ); + node_id + }) + }; + let first_help = add_help(first_parameter); + let second_help = add_help(second_parameter); + let child = builder.alloc_node_id(); + builder.push_node( + child, + constraint_class, + AssocConstraintNodeData::Geometrical { + owner_id: 0, + is_implied: false, + is_active: constraint.enabled, + }, + ); + for target in [ + Some(first_curve), + Some(second_curve), + first_help, + second_help, + ] + .into_iter() + .flatten() + { + builder.connect(child, target); + } + if let Some(first_help) = first_help { + builder.connect(first_help, first_curve); + } + if let Some(second_help) = second_help { + builder.connect(second_help, second_curve); + } + child + }; + let tangent = add_help_pair(builder, "AcTangentConstraint"); + let equal_curvature = add_help_pair(builder, "AcEqualCurvatureConstraint"); + let node_id = builder.alloc_node_id(); + builder.push_node( + node_id, + "AcG2SmoothConstraint", + AssocConstraintNodeData::Composite { + owner_id: 0, + is_implied: false, + is_active: constraint.enabled, + owned_constraint_ids: vec![coincidence, tangent, equal_curvature], + }, + ); + for child_id in [coincidence, tangent, equal_curvature] { + if let Some(AssocConstraintNode { + data: AssocConstraintNodeData::Geometrical { owner_id, .. }, + .. + }) = builder + .nodes + .iter_mut() + .find(|node| node.node_id == child_id) + { + *owner_id = node_id; + } + } + return Some(node_id); + } if let Some(class_name) = geometrical_class_name(constraint.kind) { let target_refs: Vec = refs.iter().filter_map(|r| builder.ref_node(*r)).collect(); if target_refs.len() != refs.len() { return None; } - let owner_id = *target_refs.first()?; let node_id = builder.alloc_node_id(); builder.push_node( node_id, class_name, AssocConstraintNodeData::Geometrical { - owner_id, + owner_id: 0, is_implied: false, - is_active: true, + is_active: constraint.enabled, }, ); for target in &target_refs { @@ -621,9 +858,9 @@ fn constraint_node( "AcVerticalConstraint" }, AssocConstraintNodeData::Parallel { - owner_id: targets[0], + owner_id: 0, is_implied: false, - is_active: true, + is_active: constraint.enabled, datum_line_index: Some(datum), }, ); @@ -640,9 +877,9 @@ fn constraint_node( node_id, "AcParallelConstraint", AssocConstraintNodeData::Parallel { - owner_id: na, + owner_id: 0, is_implied: false, - is_active: true, + is_active: constraint.enabled, datum_line_index: None, }, ); @@ -653,7 +890,7 @@ fn constraint_node( ConstraintKind::Equal => { let [a, b] = refs else { return None }; let (na, nb) = (builder.ref_node(*a)?, builder.ref_node(*b)?); - let is_straight = |reference: &SketchRef| { + let is_straight = |reference: &ParametricRef| { matches!( document.get_entity(reference.entity), Some(EntityType::Line(_)) @@ -674,9 +911,9 @@ fn constraint_node( node_id, class_name, AssocConstraintNodeData::Geometrical { - owner_id: na, + owner_id: 0, is_implied: false, - is_active: true, + is_active: constraint.enabled, }, ); builder.connect(node_id, na); @@ -686,29 +923,47 @@ fn constraint_node( ConstraintKind::Distance | ConstraintKind::DistanceX | ConstraintKind::DistanceY + | ConstraintKind::DistanceDirected | ConstraintKind::Angle + | ConstraintKind::Angle3Point | ConstraintKind::Radius | ConstraintKind::Diameter => { let target_refs: Vec = match constraint.kind { ConstraintKind::Radius | ConstraintKind::Diameter => { vec![builder.ref_node(*refs.first()?)?] } + ConstraintKind::Angle3Point => { + let [a, vertex, b] = refs else { return None }; + vec![ + builder.ref_node(*a)?, + builder.ref_node(*vertex)?, + builder.ref_node(*b)?, + ] + } + ConstraintKind::DistanceDirected => { + if !matches!(refs.len(), 2 | 3) { + return None; + } + refs.iter() + .map(|reference| builder.ref_node(*reference)) + .collect::>()? + } _ => { let [a, b] = refs else { return None }; vec![builder.ref_node(*a)?, builder.ref_node(*b)?] } }; - let owner_id = *target_refs.first()?; + let owner_id = 0; let node_id = builder.alloc_node_id(); let data = match constraint.kind { // Plain two-point distance: undirected (`kNotDirected`). ConstraintKind::Distance => AssocConstraintNodeData::Distance { owner_id, is_implied: false, - is_active: true, + is_active: constraint.enabled, value_dependency: Handle::NULL, dimension_dependency: Handle::NULL, - direction_type: 0, + direction_type: distance_direction_type::UNDIRECTED, distance: None, }, // X-only/Y-only: `kFixedDirection` with the fixed unit @@ -718,33 +973,50 @@ fn constraint_node( ConstraintKind::DistanceX => AssocConstraintNodeData::Distance { owner_id, is_implied: false, - is_active: true, + is_active: constraint.enabled, value_dependency: Handle::NULL, dimension_dependency: Handle::NULL, - direction_type: 1, + direction_type: distance_direction_type::FIXED, distance: Some(Vector3::new(1.0, 0.0, 0.0)), }, ConstraintKind::DistanceY => AssocConstraintNodeData::Distance { owner_id, is_implied: false, - is_active: true, + is_active: constraint.enabled, value_dependency: Handle::NULL, dimension_dependency: Handle::NULL, - direction_type: 1, + direction_type: distance_direction_type::FIXED, distance: Some(Vector3::new(0.0, 1.0, 0.0)), }, + ConstraintKind::DistanceDirected => AssocConstraintNodeData::Distance { + owner_id, + is_implied: false, + is_active: constraint.enabled, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + direction_type: constraint.distance_direction_type, + distance: constraint.distance_direction, + }, ConstraintKind::Angle => AssocConstraintNodeData::Angle { owner_id, is_implied: false, - is_active: true, + is_active: constraint.enabled, value_dependency: Handle::NULL, dimension_dependency: Handle::NULL, - sector_type: 0, + sector_type: constraint.angle_sector, + }, + ConstraintKind::Angle3Point => AssocConstraintNodeData::Angle { + owner_id, + is_implied: false, + is_active: constraint.enabled, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + sector_type: constraint.angle_sector, }, ConstraintKind::Radius => AssocConstraintNodeData::RadiusDiameter { owner_id, is_implied: false, - is_active: true, + is_active: constraint.enabled, value_dependency: Handle::NULL, dimension_dependency: Handle::NULL, mode: 0, @@ -754,7 +1026,7 @@ fn constraint_node( ConstraintKind::Diameter => AssocConstraintNodeData::RadiusDiameter { owner_id, is_implied: false, - is_active: true, + is_active: constraint.enabled, value_dependency: Handle::NULL, dimension_dependency: Handle::NULL, mode: 1, @@ -874,6 +1146,14 @@ impl Allocator<'_> { resolved: f64, ) -> Handle { if let Some(&handle) = self.variables.get(name) { + if let Some(ObjectType::Associative(object)) = self.document.objects.get_mut(&handle) { + object.owner = network_handle; + if let AssociativeData::Variable(variable) = &mut object.data { + variable.action.owning_network = network_handle; + variable.expression = formula.to_string(); + variable.value = Self::numeric_eval(resolved); + } + } return handle; } let handle = self.insert_associative( @@ -949,6 +1229,10 @@ fn set_geometry_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { geometry_dependency, .. } + | AssocConstraintNodeData::Line { + geometry_dependency, + .. + } | AssocConstraintNodeData::BoundedLine { geometry_dependency, .. @@ -968,6 +1252,10 @@ fn set_geometry_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { | AssocConstraintNodeData::BoundedEllipse { geometry_dependency, .. + } + | AssocConstraintNodeData::Spline { + geometry_dependency, + .. } => { *geometry_dependency = handle; } @@ -1036,18 +1324,8 @@ fn ensure_associative_classes_registered(document: &mut CadDocument) { } } -/// Ensures `owner` has an extension dictionary through the public API. fn ensure_extension_dictionary(document: &mut CadDocument, owner: Handle) -> Handle { - if let Some(handle) = document.extension_dictionary_handle(owner) { - return handle; - } - const BOOTSTRAP_KEY: &str = "OCS_DWG_NATIVE_BOOTSTRAP"; - document.ensure_xrecord(owner, BOOTSTRAP_KEY); - let dictionary_handle = document - .extension_dictionary_handle(owner) - .expect("ensure_xrecord just created the extension dictionary"); - remove_dictionary_entry(document, dictionary_handle, BOOTSTRAP_KEY); - dictionary_handle + document.ensure_extension_dictionary(owner) } /// Removes a dictionary entry and its owned object tree. @@ -1137,29 +1415,41 @@ fn remove_owned_recursive(document: &mut CadDocument, root: Handle) { document.objects.remove(&root); } -/// Whether `owner` already has a native graph that must stay synchronized. -fn has_native_network(document: &CadDocument, owner: Handle) -> bool { - let Some(dictionary_handle) = document.extension_dictionary_handle(owner) else { - return false; +fn native_group_handles(document: &CadDocument, owner: Handle) -> Vec { + let Some(network_handle) = native_scope_network_handle(document, owner) else { + return Vec::new(); }; - let Some(ObjectType::Dictionary(dictionary)) = document.objects.get(&dictionary_handle) else { - return false; - }; - dictionary.get(NETWORK_DICTIONARY_KEY).is_some() -} - -fn native_group(document: &CadDocument, owner: Handle) -> Option<&Assoc2dConstraintGroup> { - let network_handle = native_scope_network_handle(document, owner)?; - document.objects.values().find_map(|object| { + let mut handles = Vec::new(); + if let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::Network(network), + .. + })) = document.objects.get(&network_handle) + { + handles.extend(network.actions.iter().filter_map(|action| { + matches!( + document.objects.get(&action.dependency), + Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(_), + .. + })) + ) + .then_some(action.dependency) + })); + } + for (handle, object) in &document.objects { let ObjectType::Associative(object) = object else { - return None; + continue; }; let AssociativeData::ConstraintGroup(group) = &object.data else { - return None; + continue; }; - (object.owner == network_handle || group.action.owning_network == network_handle) - .then_some(group) - }) + if (object.owner == network_handle || group.action.owning_network == network_handle) + && !handles.contains(handle) + { + handles.push(*handle); + } + } + handles } fn native_scope_network_handle(document: &CadDocument, owner: Handle) -> Option { @@ -1210,6 +1500,10 @@ fn geometry_dependency(data: &AssocConstraintNodeData) -> Option { | AssocConstraintNodeData::BoundedEllipse { geometry_dependency, .. + } + | AssocConstraintNodeData::Spline { + geometry_dependency, + .. } if !geometry_dependency.is_null() => Some(*geometry_dependency), _ => None, } @@ -1240,10 +1534,13 @@ fn dependency_entity( ), AssocConstraintNodeData::Point { .. }, ) => true, + (Some(EntityType::Line(_)), AssocConstraintNodeData::BoundedLine { is_ray, .. }) => !is_ray, ( - Some(EntityType::Line(_) | EntityType::LwPolyline(_) | EntityType::Polyline2D(_)), - AssocConstraintNodeData::Line { .. } | AssocConstraintNodeData::BoundedLine { .. }, + Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_)), + AssocConstraintNodeData::BoundedLine { .. }, ) => true, + (Some(EntityType::Ray(_)), AssocConstraintNodeData::BoundedLine { is_ray, .. }) => *is_ray, + (Some(EntityType::XLine(_)), AssocConstraintNodeData::Line { .. }) => true, (Some(EntityType::Circle(_)), AssocConstraintNodeData::Circle { .. }) => true, (Some(EntityType::Arc(_)), AssocConstraintNodeData::Arc { .. }) => true, ( @@ -1251,25 +1548,29 @@ fn dependency_entity( AssocConstraintNodeData::Ellipse { .. } | AssocConstraintNodeData::BoundedEllipse { .. }, ) => true, + (Some(EntityType::Spline(_)), AssocConstraintNodeData::Spline { .. }) => true, _ => false, }; supported.then_some(entity) } -fn native_group_requires_preservation(document: &CadDocument, owner: Handle) -> bool { - native_group(document, owner).is_some_and(|group| { - group.nodes.iter().any(|node| { - if matches!(node.data, AssocConstraintNodeData::RigidSet { .. }) { +fn group_requires_preservation(document: &CadDocument, group: &Assoc2dConstraintGroup) -> bool { + group.nodes.iter().any(|node| { + if matches!(node.data, AssocConstraintNodeData::RigidSet { .. }) { + return true; + } + if let Some(dependency) = geometry_dependency(&node.data) { + if dependency_entity(document, dependency, &node.data).is_none() { return true; } - if let Some(dependency) = geometry_dependency(&node.data) { - if dependency_entity(document, dependency, &node.data).is_none() { - return true; - } - } - node.class_name.to_ascii_uppercase().ends_with("CONSTRAINT") - && constraint_kind(node).is_none() - }) + } + let class_name = node.class_name.to_ascii_uppercase(); + class_name.ends_with("CONSTRAINT") + && constraint_kind(node).is_none() + && !matches!( + class_name.as_str(), + "ACEQUALCURVATURECONSTRAINT" | "ACEQUALHELPPARAMETERCONSTRAINT" + ) }) } @@ -1277,7 +1578,8 @@ fn polyline_segment_reference( document: &CadDocument, entity: Handle, data: &AssocConstraintNodeData, -) -> Option { + work_plane: &[Vector3; 3], +) -> Option { let AssocConstraintNodeData::BoundedLine { start_point, end_point, @@ -1286,6 +1588,15 @@ fn polyline_segment_reference( else { return None; }; + let [origin, axis_x, axis_y] = *work_plane; + let normal = Vector3::new( + axis_x.y * axis_y.z - axis_x.z * axis_y.y, + axis_x.z * axis_y.x - axis_x.x * axis_y.z, + axis_x.x * axis_y.y - axis_x.y * axis_y.x, + ); + let to_world = |point: Vector3| origin + axis_x * point.x + axis_y * point.y + normal * point.z; + let start_point = to_world(*start_point); + let end_point = to_world(*end_point); let entity_value = document.get_entity(entity)?; let closed = match entity_value { EntityType::LwPolyline(polyline) => polyline.is_closed, @@ -1299,15 +1610,13 @@ fn polyline_segment_reference( let score = |index: usize| { let a = points[index]; let b = points[(index + 1) % points.len()]; - let forward = - (a - *start_point).length_squared() + (b - *end_point).length_squared(); - let reverse = - (b - *start_point).length_squared() + (a - *end_point).length_squared(); + let forward = (a - start_point).length_squared() + (b - end_point).length_squared(); + let reverse = (b - start_point).length_squared() + (a - end_point).length_squared(); forward.min(reverse) }; score(*first).total_cmp(&score(*second)) }) - .map(|index| SketchRef::segment(entity, index)) + .map(|index| ParametricRef::segment(entity, index)) } fn numeric_value(value: &AssocEvalVariant) -> Option { @@ -1355,9 +1664,10 @@ fn driving_value( } } -fn constraint_owner(data: &AssocConstraintNodeData) -> Option { +fn owning_composite(data: &AssocConstraintNodeData) -> Option { match data { AssocConstraintNodeData::Geometrical { owner_id, .. } + | AssocConstraintNodeData::Composite { owner_id, .. } | AssocConstraintNodeData::Angle { owner_id, .. } | AssocConstraintNodeData::Parallel { owner_id, .. } | AssocConstraintNodeData::Distance { owner_id, .. } @@ -1369,6 +1679,7 @@ fn constraint_owner(data: &AssocConstraintNodeData) -> Option { fn constraint_is_active(data: &AssocConstraintNodeData) -> bool { match data { AssocConstraintNodeData::Geometrical { is_active, .. } + | AssocConstraintNodeData::Composite { is_active, .. } | AssocConstraintNodeData::Angle { is_active, .. } | AssocConstraintNodeData::Parallel { is_active, .. } | AssocConstraintNodeData::Distance { is_active, .. } @@ -1377,15 +1688,80 @@ fn constraint_is_active(data: &AssocConstraintNodeData) -> bool { } } +fn constraint_is_implied(data: &AssocConstraintNodeData) -> bool { + match data { + AssocConstraintNodeData::Geometrical { is_implied, .. } + | AssocConstraintNodeData::Composite { is_implied, .. } + | AssocConstraintNodeData::Angle { is_implied, .. } + | AssocConstraintNodeData::Parallel { is_implied, .. } + | AssocConstraintNodeData::Distance { is_implied, .. } + | AssocConstraintNodeData::RadiusDiameter { is_implied, .. } => *is_implied, + _ => false, + } +} + +fn set_constraint_active(data: &mut AssocConstraintNodeData, active: bool) { + match data { + AssocConstraintNodeData::Geometrical { is_active, .. } + | AssocConstraintNodeData::Composite { is_active, .. } + | AssocConstraintNodeData::Angle { is_active, .. } + | AssocConstraintNodeData::Parallel { is_active, .. } + | AssocConstraintNodeData::Distance { is_active, .. } + | AssocConstraintNodeData::RadiusDiameter { is_active, .. } => *is_active = active, + _ => {} + } +} + +fn clear_removed_geometry_owner(data: &mut AssocConstraintNodeData, removed: &[i32]) { + let owner = match data { + AssocConstraintNodeData::ImplicitPoint { + geometry_node_id, .. + } + | AssocConstraintNodeData::Point { + geometry_node_id, .. + } + | AssocConstraintNodeData::RigidSet { + geometry_node_id, .. + } + | AssocConstraintNodeData::Line { + geometry_node_id, .. + } + | AssocConstraintNodeData::BoundedLine { + geometry_node_id, .. + } + | AssocConstraintNodeData::Circle { + geometry_node_id, .. + } + | AssocConstraintNodeData::Arc { + geometry_node_id, .. + } + | AssocConstraintNodeData::Ellipse { + geometry_node_id, .. + } + | AssocConstraintNodeData::BoundedEllipse { + geometry_node_id, .. + } + | AssocConstraintNodeData::Spline { + geometry_node_id, .. + } => geometry_node_id, + _ => return, + }; + if removed.contains(owner) { + *owner = 0; + } +} + fn constraint_kind(node: &AssocConstraintNode) -> Option { Some(match node.class_name.to_ascii_uppercase().as_str() { + "ACCONSTRAINEDRIGIDSET" => ConstraintKind::RigidSet, "ACPOINTCOINCIDENCECONSTRAINT" => ConstraintKind::Coincident, "ACHORIZONTALCONSTRAINT" => ConstraintKind::Horizontal, "ACVERTICALCONSTRAINT" => ConstraintKind::Vertical, "ACPARALLELCONSTRAINT" => ConstraintKind::Parallel, "ACPERPENDICULARCONSTRAINT" => ConstraintKind::Perpendicular, "ACEQUALLENGTHCONSTRAINT" | "ACEQUALRADIUSCONSTRAINT" => ConstraintKind::Equal, - "ACANGLECONSTRAINT" | "AC3POINTANGLECONSTRAINT" => ConstraintKind::Angle, + "ACANGLECONSTRAINT" => ConstraintKind::Angle, + "AC3POINTANGLECONSTRAINT" => ConstraintKind::Angle3Point, "ACTANGENTCONSTRAINT" => ConstraintKind::Tangent, "ACG2SMOOTHCONSTRAINT" => ConstraintKind::Smooth, "ACCONCENTRICCONSTRAINT" => ConstraintKind::Concentric, @@ -1416,155 +1792,441 @@ fn constraint_kind(node: &AssocConstraintNode) -> Option { else { return None; }; - if *direction_type == 0 { + if *direction_type == distance_direction_type::UNDIRECTED { ConstraintKind::Distance - } else if distance.is_some_and(|direction| direction.x.abs() >= direction.y.abs()) { + } else if *direction_type == distance_direction_type::FIXED + && distance.is_some_and(|direction| { + let length = direction.x.hypot(direction.y); + length > f64::EPSILON + && (direction.x / length - 1.0).abs() < 1.0e-9 + && (direction.y / length).abs() < 1.0e-9 + }) + { ConstraintKind::DistanceX - } else { + } else if *direction_type == distance_direction_type::FIXED + && distance.is_some_and(|direction| { + let length = direction.x.hypot(direction.y); + length > f64::EPSILON + && (direction.x / length).abs() < 1.0e-9 + && (direction.y / length - 1.0).abs() < 1.0e-9 + }) + { ConstraintKind::DistanceY + } else { + ConstraintKind::DistanceDirected } } _ => return None, }) } +fn rigid_set_references( + document: &CadDocument, + geometry_ids: &[i32], + refs: &FxHashMap, +) -> (Vec, Vec<(ParametricRef, Vector3)>) { + let mut entities = Vec::new(); + let mut points = Vec::new(); + let mut add_point = |reference: ParametricRef, point: Vector3| { + if !points.iter().any(|(existing, _)| *existing == reference) { + points.push((reference, point)); + } + }; + for geometry_id in geometry_ids { + let Some(reference) = refs.get(geometry_id).copied() else { + continue; + }; + if !entities.iter().any(|existing| *existing == reference) { + entities.push(reference); + } + let Some(entity) = document.get_entity(reference.entity) else { + continue; + }; + if let Some(marker) = reference.marker { + if let Some(point) = super::parametric_constraints::resolve_point(entity, marker) { + add_point(reference, point); + } + continue; + } + for (marker, point) in super::dimension_assoc::source_points(entity) + .into_iter() + .enumerate() + { + add_point(ParametricRef::point(reference.entity, marker as i32), point); + } + match entity { + EntityType::Circle(circle) => { + add_point(ParametricRef::center(reference.entity), circle.center_wcs()) + } + EntityType::Arc(arc) => { + add_point(ParametricRef::center(reference.entity), arc.center_wcs()) + } + _ => {} + } + } + (entities, points) +} + pub(super) fn native_constraint_set( document: &CadDocument, owner: Handle, parameters: &mut ParameterTable, -) -> Option { - let group = native_group(document, owner)?; - let mut refs = FxHashMap::default(); - for node in &group.nodes { - let Some(dependency) = geometry_dependency(&node.data) else { - continue; - }; - if let Some(entity) = dependency_entity(document, dependency, &node.data) { - let reference = polyline_segment_reference(document, entity, &node.data) - .unwrap_or_else(|| { - if matches!(node.data, AssocConstraintNodeData::Point { .. }) { - SketchRef::point(entity, 0) - } else { - SketchRef::whole(entity) +) -> Option { + let groups: Vec<_> = native_group_handles(document, owner) + .into_iter() + .filter_map(|handle| match document.objects.get(&handle) { + Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), + .. + })) => Some((handle, group, group_requires_preservation(document, group))), + _ => None, + }) + .collect(); + if groups.is_empty() { + return None; + } + let scope = if owner == document.header.model_space_block_handle { + ParametricScope::ModelSpace + } else { + ParametricScope::Block(owner) + }; + let mut set = ParametricConstraintSet::new(scope); + for (group_handle, group, retained) in groups { + if retained { + set.retained_standard_groups.push(group_handle); + } + let mut refs = FxHashMap::default(); + for node in &group.nodes { + let Some(dependency) = geometry_dependency(&node.data) else { + continue; + }; + if let Some(entity) = dependency_entity(document, dependency, &node.data) { + let reference = + polyline_segment_reference(document, entity, &node.data, &group.work_plane) + .unwrap_or_else(|| { + if matches!(node.data, AssocConstraintNodeData::Point { .. }) { + ParametricRef::point(entity, 0) + } else { + ParametricRef::whole(entity) + } + }); + refs.insert(node.node_id, reference); + } + } + for node in &group.nodes { + let AssocConstraintNodeData::ImplicitPoint { + point_type, + point_index, + curve_id, + .. + } = node.data + else { + continue; + }; + let Some(curve) = refs.get(&curve_id).copied() else { + continue; + }; + let reference = if let Some(segment) = curve.segment_index() { + let Some(entity) = document.get_entity(curve.entity) else { + continue; + }; + let point_count = super::dimension_assoc::source_points(entity).len(); + match point_type { + implicit_point_type::START => { + ParametricRef::point(curve.entity, segment as i32) } - }); + implicit_point_type::END if point_count > 0 => { + ParametricRef::point(curve.entity, ((segment + 1) % point_count) as i32) + } + implicit_point_type::MID => { + ParametricRef::segment_midpoint(curve.entity, segment) + } + _ => continue, + } + } else { + let marker = match point_type { + implicit_point_type::START => 0, + implicit_point_type::END => 1, + implicit_point_type::MID => -2, + implicit_point_type::CENTER => -3, + implicit_point_type::DEFINE => { + let Some(point_count) = group.nodes.iter().find_map(|node| { + (node.node_id == curve_id) + .then_some(&node.data) + .and_then(|data| { + let AssocConstraintNodeData::Spline { control_points, .. } = + data + else { + return None; + }; + Some(control_points.len()) + }) + }) else { + continue; + }; + if point_index == 0 { + 0 + } else if usize::try_from(point_index).ok() == point_count.checked_sub(1) { + 1 + } else { + continue; + } + } + _ => continue, + }; + ParametricRef::point(curve.entity, marker) + }; refs.insert(node.node_id, reference); } + + for node in &group.nodes { + let Some(kind) = constraint_kind(node) else { + continue; + }; + if owning_composite(&node.data).is_some_and(|owner| owner != 0) { + continue; + } + if constraint_is_implied(&node.data) { + continue; + } + let enabled = constraint_is_active(&node.data); + let (targets, rigid_points) = match &node.data { + AssocConstraintNodeData::RigidSet { geometry_ids, .. } => { + rigid_set_references(document, geometry_ids, &refs) + } + AssocConstraintNodeData::Composite { + owned_constraint_ids, + .. + } if kind == ConstraintKind::Smooth => { + let endpoints = owned_constraint_ids + .iter() + .filter_map(|id| group.nodes.iter().find(|child| child.node_id == *id)) + .find(|child| { + child + .class_name + .eq_ignore_ascii_case("AcPointCoincidenceConstraint") + }) + .map(|child| { + child + .connections + .iter() + .filter_map(|id| refs.get(id).copied()) + .collect() + }) + .unwrap_or_default(); + (endpoints, Vec::new()) + } + _ => ( + node.connections + .iter() + .filter_map(|id| refs.get(id).copied()) + .collect(), + Vec::new(), + ), + }; + if matches!( + kind, + ConstraintKind::PointOnCurve | ConstraintKind::CenterPoint + ) && targets.len() == 2 + && targets[0].entity == targets[1].entity + { + continue; + } + let parameter_table = if matches!(scope, ParametricScope::Block(_)) { + &mut set.local_parameters + } else { + &mut *parameters + }; + let driving = match &node.data { + AssocConstraintNodeData::Angle { + value_dependency, .. + } + | AssocConstraintNodeData::Distance { + value_dependency, .. + } + | AssocConstraintNodeData::RadiusDiameter { + value_dependency, .. + } => driving_value(document, *value_dependency, parameter_table), + _ => None, + }; + let valid_target_count = match kind { + ConstraintKind::Horizontal | ConstraintKind::Vertical => { + matches!(targets.len(), 1 | 2) + } + ConstraintKind::Fixed | ConstraintKind::Radius | ConstraintKind::Diameter => { + targets.len() == 1 + } + ConstraintKind::Symmetric | ConstraintKind::Angle3Point => targets.len() == 3, + ConstraintKind::DistanceDirected => matches!(targets.len(), 2 | 3), + ConstraintKind::EqualDistance => targets.len() == 4, + ConstraintKind::RigidSet => !targets.is_empty() && rigid_points.len() >= 2, + _ => targets.len() == 2, + }; + if !valid_target_count { + continue; + } + if matches!( + kind, + ConstraintKind::Distance + | ConstraintKind::DistanceX + | ConstraintKind::DistanceY + | ConstraintKind::DistanceDirected + | ConstraintKind::Angle + | ConstraintKind::Angle3Point + | ConstraintKind::Radius + | ConstraintKind::Diameter + ) && driving.is_none() + { + continue; + } + set.add(kind, targets, driving); + if let Some(constraint) = set.constraints.last_mut() { + constraint.enabled = enabled; + constraint.rigid_points = rigid_points; + if let AssocConstraintNodeData::Distance { + direction_type, + distance, + .. + } = &node.data + { + constraint.distance_direction_type = *direction_type; + constraint.distance_direction = *distance; + } + if let AssocConstraintNodeData::Angle { sector_type, .. } = &node.data { + constraint.angle_sector = *sector_type; + } + if retained { + constraint.native_origin = Some(NativeConstraintOrigin { + group: group_handle, + node: node.node_id, + }); + } + } + } } - for node in &group.nodes { - let AssocConstraintNodeData::ImplicitPoint { - point_type, - curve_id, + (!set.constraints.is_empty() || !set.retained_standard_groups.is_empty()).then_some(set) +} + +fn sync_retained_group_constraints(document: &mut CadDocument, set: &ParametricConstraintSet) { + let retained: FxHashMap<(Handle, i32), bool> = set + .constraints + .iter() + .filter_map(|constraint| { + constraint + .native_origin + .map(|origin| ((origin.group, origin.node), constraint.enabled)) + }) + .collect(); + for &group_handle in &set.retained_standard_groups { + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), .. - } = node.data + })) = document.objects.get_mut(&group_handle) else { continue; }; - let Some(curve) = refs.get(&curve_id).copied() else { - continue; - }; - let marker = if let Some(segment) = curve.segment_index() { - let point_count = - super::dimension_assoc::source_points(document.get_entity(curve.entity)?).len(); - (match point_type { - implicit_point_type::START => segment, - implicit_point_type::END if point_count > 0 => (segment + 1) % point_count, - _ => continue, - }) as i32 - } else { - match point_type { - implicit_point_type::START => 0, - implicit_point_type::END => 1, - implicit_point_type::CENTER => -3, - _ => continue, + let mut removed = Vec::new(); + for node in &mut group.nodes { + if constraint_kind(node).is_none() || constraint_is_implied(&node.data) { + continue; } - }; - refs.insert(node.node_id, SketchRef::point(curve.entity, marker)); + match retained.get(&(group_handle, node.node_id)) { + Some(active) => set_constraint_active(&mut node.data, *active), + None => removed.push(node.node_id), + } + } + if removed.is_empty() { + continue; + } + group.nodes.retain(|node| !removed.contains(&node.node_id)); + for node in &mut group.nodes { + node.connections.retain(|id| !removed.contains(id)); + clear_removed_geometry_owner(&mut node.data, &removed); + } } - - let scope = if owner == document.header.model_space_block_handle { - SketchScope::ModelSpace - } else { - SketchScope::Block(owner) - }; - let mut set = SketchConstraintSet::new(scope); - for node in &group.nodes { - let Some(kind) = constraint_kind(node) else { - continue; - }; - if !constraint_is_active(&node.data) { - continue; - } - let mut targets: Vec = node - .connections - .iter() - .filter_map(|id| refs.get(id).copied()) - .collect(); - if let Some(owner) = constraint_owner(&node.data).and_then(|id| refs.get(&id).copied()) { - if !targets.contains(&owner) { - targets.insert(0, owner); - } - } - if matches!( - kind, - ConstraintKind::PointOnCurve | ConstraintKind::CenterPoint - ) && targets.len() == 2 - && targets[0].entity == targets[1].entity - { - continue; - } - let driving = match &node.data { - AssocConstraintNodeData::Angle { - value_dependency, .. - } - | AssocConstraintNodeData::Distance { - value_dependency, .. - } - | AssocConstraintNodeData::RadiusDiameter { - value_dependency, .. - } => driving_value(document, *value_dependency, parameters), - _ => None, - }; - let expected = match kind { - ConstraintKind::Fixed | ConstraintKind::Radius | ConstraintKind::Diameter => 1, - ConstraintKind::Symmetric => 3, - ConstraintKind::EqualDistance => 4, - _ => 2, - }; - if (matches!(kind, ConstraintKind::Horizontal | ConstraintKind::Vertical) - && !matches!(targets.len(), 1 | 2)) - || (!matches!(kind, ConstraintKind::Horizontal | ConstraintKind::Vertical) - && targets.len() != expected) - { - continue; - } - if matches!( - kind, - ConstraintKind::Distance - | ConstraintKind::DistanceX - | ConstraintKind::DistanceY - | ConstraintKind::Angle - | ConstraintKind::Radius - | ConstraintKind::Diameter - ) && driving.is_none() - { - continue; - } - set.add(kind, targets, driving); - } - (!set.constraints.is_empty()).then_some(set) } -/// One scope's worth of -/// [`Scene::materialize_dwg_native_constraints_for_save`] — see that -/// method's doc comment for the save-time contract this implements. +fn detach_preserved_scope( + document: &mut CadDocument, + owner: Handle, +) -> (FxHashMap, Vec) { + let Some(network_handle) = native_scope_network_handle(document, owner) else { + return (FxHashMap::default(), Vec::new()); + }; + let preserved_groups: Vec<_> = native_group_handles(document, owner) + .into_iter() + .filter(|handle| { + matches!( + document.objects.get(handle), + Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), + .. + })) if group_requires_preservation(document, group) + ) + }) + .collect(); + if preserved_groups.is_empty() { + return (FxHashMap::default(), Vec::new()); + } + + let mut variable_handles = Vec::new(); + for group_handle in &preserved_groups { + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), + .. + })) = document.objects.get(group_handle) + else { + continue; + }; + for dependency in &group.actions { + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ValueDependency(value), + .. + })) = document.objects.get(dependency) + else { + continue; + }; + let handle = value.dependency.dependent_on; + if !handle.is_null() && !variable_handles.contains(&handle) { + variable_handles.push(handle); + } + } + } + + let dictionary = document + .extension_dictionary_handle(owner) + .unwrap_or(Handle::NULL); + for handle in &preserved_groups { + if let Some(ObjectType::Associative(object)) = document.objects.get_mut(handle) { + object.owner = dictionary; + } + } + let mut variables = FxHashMap::default(); + for handle in variable_handles { + let Some(ObjectType::Associative(object)) = document.objects.get_mut(&handle) else { + continue; + }; + object.owner = dictionary; + if let AssociativeData::Variable(variable) = &object.data { + variables.insert(variable.name.clone(), handle); + } + } + + debug_assert!(document.objects.contains_key(&network_handle)); + (variables, preserved_groups) +} + +/// Materializes one scope into the drawing's associative object graph. fn materialize_scope( document: &mut CadDocument, owner: Handle, - set: &SketchConstraintSet, + set: &ParametricConstraintSet, parameters: &ParameterTable, root_network_handle: Handle, action_index: i32, + existing_variables: FxHashMap, + preserved_groups: Vec, + materialize_all_parameters: bool, ) -> Option { let dictionary_handle = ensure_extension_dictionary(document, owner); remove_dictionary_entry(document, dictionary_handle, NETWORK_DICTIONARY_KEY); @@ -1574,9 +2236,6 @@ fn materialize_scope( let (mut nodes, entities, referenced_entities) = { let mut builder = GroupBuilder::new(document); for constraint in &set.constraints { - if !constraint.enabled { - continue; - } constraint_node( &mut builder, document, @@ -1592,33 +2251,61 @@ fn materialize_scope( } = builder; (nodes, entities, referenced_entities) }; - if nodes.is_empty() { + if nodes.is_empty() + && parameters.is_empty() + && existing_variables.is_empty() + && preserved_groups.is_empty() + { return None; } - // The stream starts with one synthetic root before the registered nodes. - nodes.insert( - 0, - AssocConstraintNode { - node_id: 0, - status: 0, - connections: Vec::new(), - class_name: String::new(), - registry_flag: false, - data: AssocConstraintNodeData::None, - }, - ); + if !nodes.is_empty() { + nodes.insert( + 0, + AssocConstraintNode { + node_id: 0, + status: 0, + connections: Vec::new(), + class_name: String::new(), + registry_flag: false, + data: AssocConstraintNodeData::None, + }, + ); + } // Pass 2 (mutable): allocate handles for everything the node shapes // above still reference as `Handle::NULL`. The group and network // reference each other, so both handles are reserved up front. - let group_handle = document.allocate_handle(); + let group_handle = (!nodes.is_empty()).then(|| document.allocate_handle()); let network_handle = document.allocate_handle(); let mut allocator = Allocator { document, - variables: FxHashMap::default(), - variable_actions: Vec::new(), + variable_actions: existing_variables.values().copied().collect(), + variables: existing_variables, }; + allocator.variable_actions.sort(); + for (index, handle) in allocator.variable_actions.iter().copied().enumerate() { + if let Some(ObjectType::Associative(object)) = allocator.document.objects.get_mut(&handle) { + object.owner = network_handle; + if let AssociativeData::Variable(variable) = &mut object.data { + variable.action.owning_network = network_handle; + variable.action.action_index = index as i32 + 1; + if let Some(parameter) = parameters.get(&variable.name) { + variable.expression = parameter.source.clone(); + if let Ok(resolved) = parameters.resolve(¶meter.name) { + variable.value = Allocator::numeric_eval(resolved); + } + } + } + } + } + + if materialize_all_parameters { + for parameter in parameters.iter() { + let resolved = parameters.resolve(¶meter.name).unwrap_or(0.0); + allocator.variable(network_handle, ¶meter.name, ¶meter.source, resolved); + } + } let mut geometry_dependencies = Vec::with_capacity(referenced_entities.len()); for (index, entity_handle) in referenced_entities.iter().enumerate() { @@ -1630,7 +2317,7 @@ fn materialize_scope( continue; } } - let dep_handle = allocator.geom_dependency(group_handle, *entity_handle, index as i32 + 1); + let dep_handle = allocator.geom_dependency(group_handle?, *entity_handle, index as i32 + 1); geometry_dependencies.push(dep_handle); for node_id in std::iter::once(entity_nodes.geometry_node_id) .filter(|node_id| *node_id != 0) @@ -1661,36 +2348,50 @@ fn materialize_scope( let variable_handle = allocator.variable(network_handle, &name, &formula, resolved); let dependency_id = group_dependencies.len() as i32 + 1; let dep_handle = - allocator.value_dependency(group_handle, variable_handle, resolved, dependency_id); + allocator.value_dependency(group_handle?, variable_handle, resolved, dependency_id); group_dependencies.push(dep_handle); if let Some(node) = nodes.iter_mut().find(|n| n.node_id == node_id) { set_value_dependency(&mut node.data, dep_handle); } } - let group = Assoc2dConstraintGroup { - action: AssocAction { - class_version: 2, - owning_network: network_handle, - action_index: allocator.variable_actions.len() as i32 + 1, - max_dependency_index: group_dependencies.len() as i32 + 1, - ..Default::default() - }, - version: 2, - flag: false, - // Only world-XY entities reach native graph materialization. - work_plane: [Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y], - dependency: Handle::NULL, - actions: group_dependencies, - nodes, - }; - allocator.insert_associative_at( - group_handle, - network_handle, - "ASSOC2DCONSTRAINTGROUP", - "AcDbAssoc2dConstraintGroup", - AssociativeData::ConstraintGroup(group), - ); + if let Some(group_handle) = group_handle { + let group = Assoc2dConstraintGroup { + action: AssocAction { + class_version: 2, + owning_network: network_handle, + action_index: allocator.variable_actions.len() as i32 + 1, + max_dependency_index: group_dependencies.len() as i32 + 1, + ..Default::default() + }, + version: 2, + flag: false, + work_plane: [Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y], + dependency: Handle::NULL, + actions: group_dependencies, + nodes, + }; + allocator.insert_associative_at( + group_handle, + network_handle, + "ASSOC2DCONSTRAINTGROUP", + "AcDbAssoc2dConstraintGroup", + AssociativeData::ConstraintGroup(group), + ); + } + + for (offset, handle) in preserved_groups.iter().copied().enumerate() { + if let Some(ObjectType::Associative(object)) = allocator.document.objects.get_mut(&handle) { + object.owner = network_handle; + if let AssociativeData::ConstraintGroup(group) = &mut object.data { + group.action.owning_network = network_handle; + group.action.action_index = allocator.variable_actions.len() as i32 + + i32::from(group_handle.is_some()) + + offset as i32 + + 1; + } + } + } let network = AssocNetwork { action: AssocAction { @@ -1700,12 +2401,15 @@ fn materialize_scope( ..Default::default() }, network_version: 0, - network_action_index: allocator.variable_actions.len() as i32 + 1, + network_action_index: allocator.variable_actions.len() as i32 + + i32::from(group_handle.is_some()) + + preserved_groups.len() as i32, actions: allocator .variable_actions .iter() .copied() - .chain(std::iter::once(group_handle)) + .chain(group_handle) + .chain(preserved_groups) .map(|dependency| AssocActionDependency { is_owned: true, dependency, @@ -1736,37 +2440,118 @@ fn materialize_scope( } impl Scene { - /// Rebuilds every sketch scope's native `AssocNetwork`/ - /// `Assoc2dConstraintGroup` graph from `self.sketch_constraints`, - /// replacing whatever this module wrote on a previous save. - /// - /// `enabled` is the app's `write_dwg_native_constraints` Options toggle - /// (off by default — this graph adds file weight every save, whether or - /// not compatible applications interop is wanted). It only gates *creating* the - /// graph in a scope that doesn't have one yet: a scope that already - /// carries one — from an earlier save with the setting on, or from a - /// file that came from native implementations — keeps getting it rebuilt in sync - /// regardless of the current setting ([`has_native_network`]), so - /// toggling this off never leaves a stale graph sitting in the file. - pub(crate) fn materialize_dwg_native_constraints_for_save(&mut self, enabled: bool) { - let mut scopes = Vec::new(); - let mut preserved_networks = Vec::new(); - for index in 0..self.sketch_constraints.len() { - let set = self.sketch_constraints[index].clone(); + pub(crate) fn load_named_parameters_from_document(&mut self) { + self.named_parameters = ParameterTable::new(); + let mut variables: Vec<_> = self + .document + .objects + .values() + .filter_map(|object| match object { + ObjectType::Associative(AssociativeObject { + handle, + data: AssociativeData::Variable(variable), + .. + }) => Some((*handle, variable)), + _ => None, + }) + .collect(); + variables.sort_by_key(|(handle, _)| handle.value()); + let model_network = native_scope_network_handle( + &self.document, + self.document.header.model_space_block_handle, + ); + for (_, variable) in variables { + if model_network.is_none_or(|network| variable.action.owning_network != network) { + continue; + } + let source = if variable.expression.trim().is_empty() { + numeric_value(&variable.value).map(|value| value.to_string()) + } else { + Some(variable.expression.clone()) + }; + if let Some(source) = source { + let _ = self.named_parameters.set(&variable.name, &source); + } + } + } + + pub(crate) fn load_parametric_constraints_from_document(&mut self) { + self.parametric_constraints.clear(); + let owners: Vec<_> = self + .document + .block_records + .iter() + .map(|record| record.handle) + .collect(); + for owner in owners { + if let Some(set) = + native_constraint_set(&self.document, owner, &mut self.named_parameters) + { + self.parametric_constraints.push(set); + } + } + } + + /// Makes the standard associative graph match the live command model. + pub(crate) fn sync_native_parametric_graph(&mut self) { + let mut owners: Vec<_> = self + .document + .block_records + .iter() + .map(|record| record.handle) + .collect(); + for set in &self.parametric_constraints { let owner = set.scope.owner_handle(&self.document); - if owner.is_null() { - continue; + if !owner.is_null() && !owners.contains(&owner) { + owners.push(owner); } - if !enabled && !has_native_network(&self.document, owner) { - continue; - } - if native_group_requires_preservation(&self.document, owner) { - if let Some(network) = native_scope_network_handle(&self.document, owner) { - preserved_networks.push(network); + } + let model_owner = self.document.header.model_space_block_handle; + if !owners.contains(&model_owner) { + owners.push(model_owner); + } + + let mut scopes = Vec::new(); + for owner in owners { + let existing = native_scope_network_handle(&self.document, owner).is_some(); + let runtime = self + .parametric_constraints + .iter() + .find(|set| set.scope.owner_handle(&self.document) == owner) + .cloned(); + let set = match runtime { + Some(set) => Some(set), + None if existing => { + native_constraint_set(&self.document, owner, &mut self.named_parameters) } + None => None, + }; + if set.is_none() + && !existing + && (owner != model_owner || self.named_parameters.is_empty()) + { continue; } - scopes.push((owner, set)); + if let Some(set) = &set { + sync_retained_group_constraints(&mut self.document, set); + } + let (variables, groups) = detach_preserved_scope(&mut self.document, owner); + let mut set = set.unwrap_or_else(|| { + if owner == model_owner { + ParametricConstraintSet::new(ParametricScope::ModelSpace) + } else { + ParametricConstraintSet::new(ParametricScope::Block(owner)) + } + }); + set.constraints.retain(|constraint| { + constraint + .native_origin + .is_none_or(|origin| !groups.contains(&origin.group)) + }); + for constraint in &mut set.constraints { + constraint.native_origin = None; + } + scopes.push((owner, set, variables, groups)); } if scopes.is_empty() { return; @@ -1780,15 +2565,23 @@ impl Scene { NETWORK_DICTIONARY_KEY, ); let root_network_handle = self.document.allocate_handle(); - let mut child_networks = preserved_networks; - for (index, (owner, set)) in scopes.into_iter().enumerate() { + let mut child_networks = Vec::new(); + for (index, (owner, set, variables, groups)) in scopes.into_iter().enumerate() { + let parameters = if set.local_parameters.is_empty() { + &self.named_parameters + } else { + &set.local_parameters + }; if let Some(handle) = materialize_scope( &mut self.document, owner, &set, - &self.named_parameters, + parameters, root_network_handle, index as i32 + 1, + variables, + groups, + owner == model_owner, ) { child_networks.push(handle); } @@ -1835,9 +2628,12 @@ impl Scene { #[cfg(test)] mod tests { - use super::super::sketch_constraints::SketchScope; + use super::super::parametric_constraints::{ + angle_sector, distance_direction_type, ParametricScope, + }; use super::*; - use acadrust::entities::{Arc, Circle, Insert, Line, LwPolyline}; + use crate::scene::ChangeKind; + use acadrust::entities::{Arc, Circle, Insert, Line, LwPolyline, Ray, Spline, XLine}; use acadrust::tables::BlockRecord; use acadrust::types::Vector2; @@ -1919,13 +2715,21 @@ mod tests { let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); let b = line_entity(&mut scene, (10.0, 0.0), (10.0, 5.0)); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(a)], + None, + ); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Vertical, vec![SketchRef::whole(b)], None); + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Vertical, + vec![ParametricRef::whole(b)], + None, + ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let owner = scene.document.header.model_space_block_handle; // Root + two geometry nodes + two datums + two constraints. let before = native_group(&scene.document, owner); @@ -1984,35 +2788,257 @@ mod tests { } } + #[test] + fn ray_and_construction_line_geometry_round_trip_through_the_standard_graph() { + for ext in ["dwg", "dxf"] { + let mut scene = Scene::new(); + let ray = scene.add_entity(EntityType::Ray(Ray::new( + Vector3::ZERO, + Vector3::new(1.0, 1.0, 0.0), + ))); + let xline = scene.add_entity(EntityType::XLine(XLine::new( + Vector3::new(4.0, 0.0, 0.0), + Vector3::UNIT_Y, + ))); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(ray)], + None, + ); + set.add( + ConstraintKind::Parallel, + vec![ParametricRef::whole(ray), ParametricRef::whole(xline)], + None, + ); + scene.sync_native_parametric_graph(); + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|error| panic!("save {ext}: {error}")); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes(&format!("line_kinds.{ext}"), bytes) + .unwrap_or_else(|error| panic!("reload {ext}: {error}")); + + let owner = restored.document.header.model_space_block_handle; + let group = native_group(&restored.document, owner); + assert!(group.nodes.iter().any(|node| { + matches!( + node.data, + AssocConstraintNodeData::BoundedLine { is_ray: true, .. } + ) + })); + assert!(group.nodes.iter().any(|node| { + node.class_name == "AcConstrainedLine" + && matches!(node.data, AssocConstraintNodeData::Line { .. }) + })); + + restored.load_parametric_constraints_from_document(); + let set = restored + .parametric_constraint_set(ParametricScope::ModelSpace) + .expect("constraints should import"); + assert!(set + .constraints + .iter() + .any(|constraint| constraint.kind == ConstraintKind::Horizontal)); + assert!(set + .constraints + .iter() + .any(|constraint| constraint.kind == ConstraintKind::Parallel)); + } + } + + #[test] + fn implicit_midpoint_round_trips_as_standard_implied_relations() { + for ext in ["dwg", "dxf"] { + let mut scene = Scene::new(); + let carrier = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let attached = line_entity(&mut scene, (5.0, 0.0), (5.0, 4.0)); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Coincident, + vec![ + ParametricRef::point(carrier, -2), + ParametricRef::point(attached, 0), + ], + None, + ); + scene.sync_native_parametric_graph(); + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|error| panic!("save {ext}: {error}")); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes(&format!("midpoint.{ext}"), bytes) + .unwrap_or_else(|error| panic!("reload {ext}: {error}")); + let owner = restored.document.header.model_space_block_handle; + let group = native_group(&restored.document, owner); + + assert!(group.nodes.iter().any(|node| matches!( + node.data, + AssocConstraintNodeData::ImplicitPoint { + point_type: implicit_point_type::MID, + .. + } + ))); + for class_name in ["AcPointCurveConstraint", "AcMidPointConstraint"] { + assert!(group.nodes.iter().any(|node| { + node.class_name == class_name + && matches!( + node.data, + AssocConstraintNodeData::Geometrical { + is_implied: true, + is_active: true, + .. + } + ) + })); + } + + restored.load_parametric_constraints_from_document(); + let set = restored + .parametric_constraint_set(ParametricScope::ModelSpace) + .expect("coincidence should import"); + assert_eq!(set.constraints.len(), 1); + assert_eq!(set.constraints[0].kind, ConstraintKind::Coincident); + assert!(set.constraints[0] + .refs + .iter() + .any(|reference| reference.entity == carrier && reference.marker == Some(-2))); + } + } + + #[test] + fn spline_smoothness_round_trips_as_a_standard_composite_graph() { + for ext in ["dwg", "dxf"] { + let mut scene = Scene::new(); + let first = scene.add_entity(EntityType::Spline(Spline::from_control_points( + 3, + vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(2.0, 0.0, 0.0), + Vector3::new(4.0, 2.0, 0.0), + Vector3::new(6.0, 2.0, 0.0), + ], + ))); + let second = line_entity(&mut scene, (6.0, 2.0), (12.0, 2.0)); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Smooth, + vec![ + ParametricRef::point(first, 1), + ParametricRef::point(second, 0), + ], + None, + ); + scene.sync_native_parametric_graph(); + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|error| panic!("save {ext}: {error}")); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes(&format!("smooth.{ext}"), bytes) + .unwrap_or_else(|error| panic!("reload {ext}: {error}")); + let owner = restored.document.header.model_space_block_handle; + let group = native_group(&restored.document, owner); + + let spline_nodes: Vec<_> = group + .nodes + .iter() + .filter_map(|node| match &node.data { + AssocConstraintNodeData::Spline { + implicit_point_ids, .. + } => Some(implicit_point_ids), + _ => None, + }) + .collect(); + assert_eq!(spline_nodes.len(), 1, "{ext}: spline graph node is missing"); + assert!(spline_nodes.iter().all(|points| points.len() == 4)); + assert_eq!( + group + .nodes + .iter() + .filter(|node| matches!( + node.data, + AssocConstraintNodeData::HelpParameter { .. } + )) + .count(), + 2, + "{ext}: the spline side needs one parameter for each child" + ); + let composite = group + .nodes + .iter() + .find(|node| node.class_name == "AcG2SmoothConstraint") + .expect("smooth composite should exist"); + let AssocConstraintNodeData::Composite { + owned_constraint_ids, + .. + } = &composite.data + else { + panic!("{ext}: smooth node should carry composite data"); + }; + assert_eq!(owned_constraint_ids.len(), 3); + assert!(owned_constraint_ids.iter().all(|child_id| { + group.nodes.iter().any(|child| { + child.node_id == *child_id + && owning_composite(&child.data) == Some(composite.node_id) + }) + })); + + restored.load_parametric_constraints_from_document(); + let constraints = &restored + .parametric_constraint_set(ParametricScope::ModelSpace) + .expect("smooth constraint should import") + .constraints; + assert_eq!( + constraints.len(), + 1, + "{ext}: composite children stay internal" + ); + assert_eq!(constraints[0].kind, ConstraintKind::Smooth); + assert_eq!( + constraints[0].refs, + vec![ + ParametricRef::point(first, 1), + ParametricRef::point(second, 0) + ] + ); + } + } + #[test] fn native_only_constraints_and_parameters_are_imported() { let mut scene = Scene::new(); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); let b = line_entity(&mut scene, (0.0, 5.0), (10.0, 5.0)); scene.named_parameters.set("gap", "5").unwrap(); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(a)], + None, + ); set.add( ConstraintKind::Parallel, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); set.add( ConstraintKind::DistanceY, - vec![SketchRef::point(a, 0), SketchRef::point(b, 0)], + vec![ParametricRef::point(a, 0), ParametricRef::point(b, 0)], Some(DrivingValue::Named("gap".to_string())), ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) .expect("save native-only graph"); let mut restored = Scene::new(); restored.document = crate::io::load_bytes("native_only.dwg", bytes).unwrap(); restored.load_named_parameters_from_document(); - restored.load_sketch_constraints_from_document(); + restored.load_parametric_constraints_from_document(); let set = restored - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .expect("native group should become an editable constraint set"); let kinds: Vec<_> = set .constraints @@ -2034,6 +3060,158 @@ mod tests { ); } + #[test] + fn equal_parameter_names_remain_independent_across_block_scopes() { + let mut scene = Scene::new(); + let mut block_handles = Vec::new(); + let mut line_handles = Vec::new(); + for (index, length) in [5.0, 9.0].into_iter().enumerate() { + let mut block = BlockRecord::new(format!("fixture_{index}")); + block.handle = scene.document.allocate_handle(); + let block_handle = block.handle; + scene.document.block_records.add(block).unwrap(); + + let mut line = Line::from_points(Vector3::ZERO, Vector3::new(length, 0.0, 0.0)); + line.common.owner_handle = block_handle; + let line_handle = scene.document.add_entity(EntityType::Line(line)).unwrap(); + let set = scene.parametric_constraint_set_mut(ParametricScope::Block(block_handle)); + set.local_parameters + .set("width", &length.to_string()) + .unwrap(); + set.add( + ConstraintKind::DistanceX, + vec![ + ParametricRef::point(line_handle, 0), + ParametricRef::point(line_handle, 1), + ], + Some(DrivingValue::Named("width".to_string())), + ); + block_handles.push(block_handle); + line_handles.push(line_handle); + } + + scene.sync_native_parametric_graph(); + let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) + .expect("save block parameter scopes"); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes("block_parameters.dwg", bytes).unwrap(); + restored.load_named_parameters_from_document(); + restored.load_parametric_constraints_from_document(); + + assert!(restored.named_parameters.is_empty()); + for (index, block_handle) in block_handles.into_iter().enumerate() { + let set = restored + .parametric_constraint_set(ParametricScope::Block(block_handle)) + .expect("block constraint scope should survive"); + assert_eq!(set.local_parameters.resolve("width"), Ok([5.0, 9.0][index])); + assert_eq!( + set.constraints[0].driving_param, + Some(DrivingValue::Named("width".to_string())) + ); + } + + let changes: Vec<_> = line_handles + .into_iter() + .map(|handle| (handle, ChangeKind::Modified)) + .collect(); + restored.refresh_parametric_constraints(&changes); + } + + #[test] + fn standalone_named_parameters_round_trip_as_standard_variables() { + for ext in ["dwg", "dxf"] { + let mut scene = Scene::new(); + let xrecords_before = scene + .document + .objects + .values() + .filter(|object| matches!(object, ObjectType::XRecord(_))) + .count(); + scene.named_parameters.set("width", "12").unwrap(); + scene.named_parameters.set("height", "width / 2").unwrap(); + scene.sync_native_parametric_graph(); + + assert_eq!( + scene + .document + .objects + .values() + .filter(|object| matches!(object, ObjectType::XRecord(_))) + .count(), + xrecords_before, + "standard parameters must not create application records" + ); + assert!(has_variable_named(&scene.document, "width")); + assert!(has_variable_named(&scene.document, "height")); + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes(&format!("parameters.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); + restored.load_named_parameters_from_document(); + + assert_eq!(restored.named_parameters.resolve("width"), Ok(12.0)); + assert_eq!(restored.named_parameters.resolve("height"), Ok(6.0)); + } + } + + #[test] + fn command_style_add_and_remove_sync_the_standard_graph_immediately() { + let mut scene = Scene::new(); + let line = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let id = scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(line)], + None, + ); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let owner = scene.document.header.model_space_block_handle; + assert!(native_group(&scene.document, owner) + .nodes + .iter() + .any(|node| node.class_name == "AcHorizontalConstraint")); + + assert!(scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .remove(id)); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + assert!(native_scope_network_handle(&scene.document, owner).is_none()); + } + + #[test] + fn inactive_constraints_round_trip_without_becoming_active() { + for ext in ["dwg", "dxf"] { + let mut scene = Scene::new(); + let line = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(line)], + None, + ); + set.constraints[0].enabled = false; + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes(&format!("inactive.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); + restored.load_parametric_constraints_from_document(); + + let constraint = &restored + .parametric_constraint_set(ParametricScope::ModelSpace) + .expect("constraint set should survive") + .constraints[0]; + assert_eq!(constraint.kind, ConstraintKind::Horizontal); + assert!(!constraint.enabled); + } + } + #[test] fn point_and_two_point_horizontal_constraints_round_trip_natively() { let mut scene = Scene::new(); @@ -2048,27 +3226,30 @@ mod tests { Vector3::new(3.0, 4.0, 0.0), ))); let line = line_entity(&mut scene, (5.0, 7.0), (10.0, 9.0)); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); set.add( ConstraintKind::Horizontal, - vec![SketchRef::point(point, 0), SketchRef::point(insert, 0)], + vec![ + ParametricRef::point(point, 0), + ParametricRef::point(insert, 0), + ], None, ); set.add( ConstraintKind::Midpoint, - vec![SketchRef::point(point, 0), SketchRef::whole(line)], + vec![ParametricRef::point(point, 0), ParametricRef::whole(line)], None, ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) .expect("save point graph"); let mut restored = Scene::new(); restored.document = crate::io::load_bytes("point_constraints.dwg", bytes).unwrap(); - restored.load_sketch_constraints_from_document(); + restored.load_parametric_constraints_from_document(); let constraints = &restored - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .expect("native point constraints should be imported") .constraints; assert_eq!(constraints.len(), 2); @@ -2086,28 +3267,28 @@ mod tests { Vector2::new(10.0, 7.0), ]))); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Horizontal, - vec![SketchRef::segment(polyline, 1)], + vec![ParametricRef::segment(polyline, 1)], None, ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) .expect("save polyline segment graph"); let mut restored = Scene::new(); restored.document = crate::io::load_bytes("polyline_segment.dwg", bytes).unwrap(); - restored.load_sketch_constraints_from_document(); + restored.load_parametric_constraints_from_document(); let set = restored - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .expect("native segment constraint should be imported"); assert_eq!(set.constraints.len(), 1); assert_eq!(set.constraints[0].kind, ConstraintKind::Horizontal); assert_eq!( set.constraints[0].refs, - vec![SketchRef::segment(polyline, 1)] + vec![ParametricRef::segment(polyline, 1)] ); } @@ -2120,10 +3301,10 @@ mod tests { .set("gap", "5") .expect("defining the parameter should succeed"); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Distance, - vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], + vec![ParametricRef::point(a, 1), ParametricRef::point(b, 0)], Some(DrivingValue::Named("gap".to_string())), ); let owner = scene.document.header.model_space_block_handle; @@ -2140,14 +3321,10 @@ mod tests { }) } - /// DWG is the format this module's dependency chain (`AssocGeomDependency` - /// / `AssocValueDependency` / `AssocVariable`) actually survives a save - /// through — see [`a_dxf_save_keeps_only_the_constraint_group_shell`] for - /// the DXF side of this same scene and why it's different, not a bug here. #[test] fn a_dwg_save_keeps_the_full_dependency_chain_including_the_named_variable() { let (mut scene, owner) = distance_with_named_parameter_scene(); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); assert!( has_variable_named(&scene.document, "gap"), "expected an AssocVariable BEFORE serialization" @@ -2234,11 +3411,10 @@ mod tests { assert!(network.actions.iter().all(|action| action.is_owned)); } - /// DXF currently keeps the graph shell but omits dependency objects. #[test] - fn a_dxf_save_keeps_only_the_constraint_group_shell() { + fn a_dxf_save_keeps_the_full_constraint_dependency_chain() { let (mut scene, owner) = distance_with_named_parameter_scene(); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let bytes = crate::io::save_to_bytes(&scene.document, "dxf", scene.document.version) .unwrap_or_else(|e| panic!("save to dxf: {e}")); @@ -2251,7 +3427,7 @@ mod tests { .nodes .iter() .any(|n| n.class_name == "AcDistanceConstraint"), - "expected the distance constraint node's shell to survive, got {:?}", + "expected the distance constraint node to survive, got {:?}", group .nodes .iter() @@ -2259,8 +3435,8 @@ mod tests { .collect::>() ); assert!( - !has_variable_named(&reloaded, "gap"), - "acadrust's DXF writer should not carry AssocVariable; update the native materializer if this changes" + has_variable_named(&reloaded, "gap"), + "expected the named variable to survive a DXF round trip" ); } @@ -2269,12 +3445,16 @@ mod tests { let mut scene = Scene::new(); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(a)], + None, + ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let first_count = scene.document.objects.len(); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let second_count = scene.document.objects.len(); assert_eq!( first_count, second_count, @@ -2283,13 +3463,17 @@ mod tests { } #[test] - fn resaving_preserves_a_native_group_with_uneditable_geometry() { + fn a_standard_rigid_set_and_its_constraints_remain_editable() { let mut scene = Scene::new(); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); - scene.materialize_dwg_native_constraints_for_save(true); + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(a)], + None, + ); + scene.sync_native_parametric_graph(); let owner = scene.document.header.model_space_block_handle; let group_handle = native_group_handle(&scene.document, owner); @@ -2300,40 +3484,103 @@ mod tests { else { panic!("expected a native constraint group"); }; + let geometry_id = group + .nodes + .iter() + .find(|node| node.class_name == "AcConstrainedBoundedLine") + .map(|node| node.node_id) + .expect("line geometry should exist"); + for node in &mut group.nodes { + if node.node_id == geometry_id { + if let AssocConstraintNodeData::BoundedLine { + geometry_node_id, .. + } = &mut node.data + { + *geometry_node_id = 99; + } + } + } group.nodes.push(AssocConstraintNode { node_id: 99, class_name: "AcConstrainedRigidSet".to_string(), data: AssocConstraintNodeData::RigidSet { geometry_dependency: Handle::NULL, - geometry_node_id: 99, + geometry_node_id: 0, reserved: false, - transform: [0.0; 16], - geometry_ids: vec![1], + transform: [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ], + geometry_ids: vec![geometry_id], }, ..Default::default() }); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Vertical, vec![SketchRef::whole(a)], None); - scene.materialize_dwg_native_constraints_for_save(true); + scene.load_parametric_constraints_from_document(); + let imported = scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .expect("standard group should be imported"); + assert!(imported + .constraints + .iter() + .any(|constraint| constraint.kind == ConstraintKind::Horizontal)); + assert!(imported + .constraints + .iter() + .any(|constraint| constraint.kind == ConstraintKind::RigidSet)); - let group = native_group(&scene.document, owner); - assert!(group + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Vertical, + vec![ParametricRef::whole(a)], + None, + ); + scene.sync_native_parametric_graph(); + + let groups: Vec<_> = native_group_handles(&scene.document, owner) + .into_iter() + .filter_map(|handle| match scene.document.objects.get(&handle) { + Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), + .. + })) => Some(group), + _ => None, + }) + .collect(); + assert!(groups.iter().any(|group| group .nodes .iter() - .any(|node| matches!(node.data, AssocConstraintNodeData::RigidSet { .. }))); - assert!(!group + .any(|node| matches!(node.data, AssocConstraintNodeData::RigidSet { .. })))); + assert!(groups.iter().any(|group| group .nodes .iter() - .any(|node| node.class_name == "AcVerticalConstraint")); + .any(|node| node.class_name == "AcVerticalConstraint"))); + + let horizontal = scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .constraints + .iter_mut() + .find(|constraint| constraint.kind == ConstraintKind::Horizontal) + .expect("imported horizontal constraint"); + horizontal.enabled = false; + scene.sync_native_parametric_graph(); + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), + .. + })) = scene.document.objects.get(&group_handle) + else { + panic!("retained group should exist"); + }; + assert!(group.nodes.iter().any(|node| { + node.class_name == "AcHorizontalConstraint" && !constraint_is_active(&node.data) + })); } #[test] fn an_empty_constraint_set_leaves_no_native_network() { let mut scene = Scene::new(); - let _ = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - scene.materialize_dwg_native_constraints_for_save(true); + let _ = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + scene.sync_native_parametric_graph(); let owner = scene.document.header.model_space_block_handle; let dict = scene.document.extension_dictionary_handle(owner); @@ -2348,77 +3595,29 @@ mod tests { } fn model_space_has_native_network(scene: &Scene) -> bool { - has_native_network( + native_scope_network_handle( &scene.document, scene.document.header.model_space_block_handle, ) + .is_some() } #[test] - fn the_setting_off_skips_a_scope_with_no_existing_native_graph() { + fn sync_creates_the_standard_graph_for_a_new_constraint() { let mut scene = Scene::new(); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(a)], + None, + ); - scene.materialize_dwg_native_constraints_for_save(false); - assert!( - !model_space_has_native_network(&scene), - "a scope with no prior native graph should stay untouched while the setting is off" - ); - } - - /// The "sync-if-present, create-only-if-enabled" rule from - /// `Scene::materialize_dwg_native_constraints_for_save`'s doc comment: - /// once a scope has the native graph (here, from an earlier save with - /// the setting on), turning the setting off must not freeze that graph - /// out of date — it should keep tracking the current constraints. - #[test] - fn the_setting_off_still_resyncs_a_scope_that_already_has_a_native_graph() { - let mut scene = Scene::new(); - let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); assert!( model_space_has_native_network(&scene), - "sanity: the graph should exist after the first, enabled save" - ); - - let b = line_entity(&mut scene, (0.0, 0.0), (0.0, 10.0)); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Vertical, vec![SketchRef::whole(b)], None); - scene.materialize_dwg_native_constraints_for_save(false); - - let owner = scene.document.header.model_space_block_handle; - let group = native_group(&scene.document, owner); - assert!( - group.nodes.iter().any(|n| n.class_name == "AcVerticalConstraint"), - "the second, disabled-setting save should still pick up the new Vertical constraint, got {:?}", - group.nodes.iter().map(|n| &n.class_name).collect::>() - ); - } - - /// The other half of the same rule: disabling the setting after a scope - /// already has the graph must not make that graph disappear either — - /// only remove it by clearing the scope's constraints (existing - /// `an_empty_constraint_set_leaves_no_native_network` behavior), not by - /// toggling the setting off. - #[test] - fn the_setting_off_does_not_remove_an_existing_native_graph() { - let mut scene = Scene::new(); - let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); - scene.materialize_dwg_native_constraints_for_save(true); - scene.materialize_dwg_native_constraints_for_save(false); - assert!( - model_space_has_native_network(&scene), - "an existing native graph must survive a disabled-setting resave, not be silently dropped" + "a new constraint should immediately create its standard graph" ); } @@ -2439,59 +3638,75 @@ mod tests { let marker = line_entity(&mut scene, (20.0, 20.0), (21.0, 21.0)); let axis = line_entity(&mut scene, (0.0, 0.0), (0.0, 10.0)); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); set.add( ConstraintKind::Concentric, - vec![SketchRef::center(circle_a), SketchRef::center(circle_b)], + vec![ + ParametricRef::center(circle_a), + ParametricRef::center(circle_b), + ], None, ); set.add( ConstraintKind::Colinear, - vec![SketchRef::whole(line_a), SketchRef::whole(line_b)], + vec![ParametricRef::whole(line_a), ParametricRef::whole(line_b)], + None, + ); + set.add( + ConstraintKind::Fixed, + vec![ParametricRef::whole(line_a)], None, ); - set.add(ConstraintKind::Fixed, vec![SketchRef::whole(line_a)], None); set.add( ConstraintKind::CenterPoint, - vec![SketchRef::point(marker, 0), SketchRef::center(circle_a)], + vec![ + ParametricRef::point(marker, 0), + ParametricRef::center(circle_a), + ], None, ); set.add( ConstraintKind::Midpoint, - vec![SketchRef::point(marker, 1), SketchRef::whole(line_a)], + vec![ + ParametricRef::point(marker, 1), + ParametricRef::whole(line_a), + ], None, ); set.add( ConstraintKind::PointOnCurve, - vec![SketchRef::point(marker, 0), SketchRef::whole(circle_a)], + vec![ + ParametricRef::point(marker, 0), + ParametricRef::whole(circle_a), + ], None, ); set.add( ConstraintKind::EqualDistance, vec![ - SketchRef::point(line_b, 0), - SketchRef::point(line_b, 1), - SketchRef::point(line_a, 0), - SketchRef::point(line_a, 1), + ParametricRef::point(line_b, 0), + ParametricRef::point(line_b, 1), + ParametricRef::point(line_a, 0), + ParametricRef::point(line_a, 1), ], None, ); set.add( ConstraintKind::Symmetric, vec![ - SketchRef::center(circle_a), - SketchRef::center(circle_b), - SketchRef::whole(axis), + ParametricRef::center(circle_a), + ParametricRef::center(circle_b), + ParametricRef::whole(axis), ], None, ); set.add( ConstraintKind::Normal, - vec![SketchRef::whole(circle_a), SketchRef::whole(line_a)], + vec![ParametricRef::whole(circle_a), ParametricRef::whole(line_a)], None, ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let owner = scene.document.header.model_space_block_handle; let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) @@ -2523,7 +3738,7 @@ mod tests { } /// Constraint-parity Phase 1: an Arc registers in the solver as its own - /// center/radius (`sketch_solve.rs`'s `EntityGeom::Circle` reuse), and + /// center/radius (`parametric_solve.rs`'s `EntityGeom::Circle` reuse), and /// this module already had constrained-arc geometry-dependency /// support in place before that. This confirms the two sides actually /// meet: a Concentric/Tangent/Radius constraint referencing an arc @@ -2537,19 +3752,19 @@ mod tests { let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0); let circle = circle_entity(&mut scene, (10.0, -6.0), 2.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); set.add( ConstraintKind::Concentric, - vec![SketchRef::center(arc), SketchRef::center(circle)], + vec![ParametricRef::center(arc), ParametricRef::center(circle)], None, ); set.add( ConstraintKind::Radius, - vec![SketchRef::whole(arc)], + vec![ParametricRef::whole(arc)], Some(crate::scene::named_parameters::DrivingValue::Literal(9.0)), ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let owner = scene.document.header.model_space_block_handle; let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) @@ -2580,7 +3795,7 @@ mod tests { /// Constraint-parity Phase 4b: a Coincident constraint referencing an /// arc's actual *endpoint* (marker 0, not its center via `-3` or its - /// whole curve) round-trips correctly too — `SketchRef`'s marker is + /// whole curve) round-trips correctly too — `ParametricRef`'s marker is /// serialized generically regardless of what entity type or point it /// addresses, so this needs no dedicated DWG-side wiring beyond what /// already existed; this test is here to actually confirm that rather @@ -2593,14 +3808,14 @@ mod tests { let line = line_entity(&mut scene, (20.0, 20.0), (21.0, 20.0)); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Coincident, - vec![SketchRef::point(arc, 0), SketchRef::point(line, 0)], + vec![ParametricRef::point(arc, 0), ParametricRef::point(line, 0)], None, ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let owner = scene.document.header.model_space_block_handle; let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) @@ -2638,16 +3853,22 @@ mod tests { )); let circle = circle_entity(&mut scene, (10.0, 10.0), 2.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); set.add( ConstraintKind::Concentric, - vec![SketchRef::center(ellipse), SketchRef::center(circle)], + vec![ + ParametricRef::center(ellipse), + ParametricRef::center(circle), + ], + None, + ); + set.add( + ConstraintKind::Fixed, + vec![ParametricRef::whole(circle)], None, ); - set.add(ConstraintKind::Fixed, vec![SketchRef::whole(circle)], None); - scene.materialize_sketch_constraints_for_save(); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let owner = scene.document.header.model_space_block_handle; let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) @@ -2673,85 +3894,16 @@ mod tests { let mut reloaded_scene = Scene::new(); reloaded_scene.document = reloaded_doc; - reloaded_scene.load_sketch_constraints_from_document(); + reloaded_scene.load_parametric_constraints_from_document(); let restored = reloaded_scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap_or_else(|| { panic!("{ext}: no ModelSpace constraint set survived the round trip") }); let kinds: Vec = restored.constraints.iter().map(|c| c.kind).collect(); assert!( kinds.contains(&ConstraintKind::Concentric), - "{ext}: the ellipse constraint should still round-trip via XRecord, got {kinds:?}" - ); - } - } - - /// Constraint-parity Phase 4a: `ArcLength` has no native SDK class at - /// all (confirmed against `AcExplicitConstr.h`) — this asserts the - /// degradation is exactly as documented: the native DWG/DXF graph - /// gets every *other* constraint on the arc (Radius, here) but no - /// ArcLength-shaped node, while the app's own constraint set (what - /// XRecord persistence actually serializes, per `sketch_persist.rs`'s - /// fully-generic encode/decode) still carries it untouched. - #[test] - fn arc_length_has_no_native_class_but_survives_in_the_apps_own_constraint_set() { - for ext in ["dxf", "dwg"] { - let mut scene = Scene::new(); - let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0); - - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add( - ConstraintKind::Radius, - vec![SketchRef::whole(arc)], - Some(DrivingValue::Literal(4.0)), - ); - set.add( - ConstraintKind::ArcLength, - vec![SketchRef::whole(arc)], - Some(DrivingValue::Literal(6.0)), - ); - - // Both materializers, "called alongside, not instead of" each - // other (`materialize_dwg_native_constraints_for_save`'s own - // doc comment) — the native graph and this app's own XRecord - // format are independent, additive persistence paths. - scene.materialize_sketch_constraints_for_save(); - scene.materialize_dwg_native_constraints_for_save(true); - let owner = scene.document.header.model_space_block_handle; - - let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) - .unwrap_or_else(|e| panic!("save to {ext}: {e}")); - let reloaded_doc = crate::io::load_bytes(&format!("arc_length.{ext}"), bytes) - .unwrap_or_else(|e| panic!("reload {ext}: {e}")); - - let group = native_group(&reloaded_doc, owner); - let class_names: Vec<&str> = - group.nodes.iter().map(|n| n.class_name.as_str()).collect(); - assert!( - class_names.contains(&"AcRadiusDiameterConstraint"), - "{ext}: missing radius/diameter constraint, got {class_names:?}" - ); - assert!( - !class_names.iter().any(|n| n.contains("ARCLENGTH")), - "{ext}: ArcLength has no native DWG class and must not appear here, got {class_names:?}" - ); - - // Still present in this app's own (XRecord-backed) constraint - // set — reload into a fresh `Scene` and read it back the same - // way `sketch_persist.rs`'s own round-trip tests do. - let mut reloaded_scene = Scene::new(); - reloaded_scene.document = reloaded_doc; - reloaded_scene.load_sketch_constraints_from_document(); - let restored = reloaded_scene - .sketch_constraint_set(SketchScope::ModelSpace) - .unwrap_or_else(|| { - panic!("{ext}: no ModelSpace constraint set survived the round trip") - }); - let kinds: Vec = restored.constraints.iter().map(|c| c.kind).collect(); - assert!( - kinds.contains(&ConstraintKind::ArcLength), - "{ext}: ArcLength should still round-trip via XRecord, got {kinds:?}" + "{ext}: the ellipse constraint should round-trip through the associative graph, got {kinds:?}" ); } } @@ -2770,24 +3922,24 @@ mod tests { let circle = circle_entity(&mut scene, (0.0, 0.0), 3.0); let line = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); set.add( ConstraintKind::Diameter, - vec![SketchRef::whole(circle)], + vec![ParametricRef::whole(circle)], Some(DrivingValue::Literal(16.0)), ); set.add( ConstraintKind::DistanceX, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(DrivingValue::Literal(10.0)), ); set.add( ConstraintKind::DistanceY, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(DrivingValue::Literal(3.0)), ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let owner = scene.document.header.model_space_block_handle; let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) @@ -2833,4 +3985,128 @@ mod tests { ); } } + + #[test] + fn line_relative_distance_and_angle_sectors_round_trip() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let measured = line_entity(&mut scene, (0.0, 0.0), (3.0, 4.0)); + let direction = line_entity(&mut scene, (-2.0, -2.0), (2.0, 2.0)); + + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + let distance_id = set.add( + ConstraintKind::DistanceDirected, + vec![ + ParametricRef::point(measured, 0), + ParametricRef::point(measured, 1), + ParametricRef::whole(direction), + ], + Some(DrivingValue::Literal(7.0)), + ); + let distance = set + .constraints + .iter_mut() + .find(|constraint| constraint.id == distance_id) + .unwrap(); + distance.distance_direction_type = distance_direction_type::PARALLEL_TO_LINE; + distance.distance_direction = Some(Vector3::new( + std::f64::consts::FRAC_1_SQRT_2, + std::f64::consts::FRAC_1_SQRT_2, + 0.0, + )); + + let angle_id = set.add( + ConstraintKind::Angle, + vec![ + ParametricRef::whole(measured), + ParametricRef::whole(direction), + ], + Some(DrivingValue::Literal(30.0)), + ); + set.constraints + .iter_mut() + .find(|constraint| constraint.id == angle_id) + .unwrap() + .angle_sector = angle_sector::ANTIPARALLEL_COUNTERCLOCKWISE; + + let angle3_id = set.add( + ConstraintKind::Angle3Point, + vec![ + ParametricRef::point(measured, 0), + ParametricRef::point(measured, 1), + ParametricRef::point(direction, 1), + ], + Some(DrivingValue::Literal(45.0)), + ); + set.constraints + .iter_mut() + .find(|constraint| constraint.id == angle3_id) + .unwrap() + .angle_sector = angle_sector::ANTIPARALLEL_CLOCKWISE; + + scene.sync_native_parametric_graph(); + let owner = scene.document.header.model_space_block_handle; + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|error| panic!("save to {ext}: {error}")); + let document = crate::io::load_bytes(&format!("directed.{ext}"), bytes) + .unwrap_or_else(|error| panic!("reload {ext}: {error}")); + + let group = native_group(&document, owner); + assert!(group.nodes.iter().any(|node| matches!( + node.data, + AssocConstraintNodeData::Distance { + direction_type: distance_direction_type::PARALLEL_TO_LINE, + .. + } + ))); + let mut sectors: Vec<_> = group + .nodes + .iter() + .filter_map(|node| match node.data { + AssocConstraintNodeData::Angle { sector_type, .. } => Some(sector_type), + _ => None, + }) + .collect(); + sectors.sort_unstable(); + assert_eq!( + sectors, + vec![ + angle_sector::ANTIPARALLEL_CLOCKWISE, + angle_sector::ANTIPARALLEL_COUNTERCLOCKWISE, + ] + ); + + let mut restored = Scene::new(); + restored.document = document; + restored.load_named_parameters_from_document(); + restored.load_parametric_constraints_from_document(); + let restored = restored + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap(); + let distance = restored + .constraints + .iter() + .find(|constraint| constraint.kind == ConstraintKind::DistanceDirected) + .unwrap(); + assert_eq!( + distance.distance_direction_type, + distance_direction_type::PARALLEL_TO_LINE + ); + assert_eq!(distance.refs.len(), 3); + let mut restored_sectors: Vec<_> = restored + .constraints + .iter() + .filter(|constraint| { + matches!( + constraint.kind, + ConstraintKind::Angle | ConstraintKind::Angle3Point + ) + }) + .map(|constraint| constraint.angle_sector) + .collect(); + restored_sectors.sort_unstable(); + assert_eq!(restored_sectors, sectors); + } + } + } diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 26d122d7f..dc42e7636 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -146,8 +146,7 @@ impl Scene { fn prepare_section_objects(&mut self, entity: &mut EntityType) -> bool { use acadrust::entities::ExtendedEntityData; use acadrust::objects::{ - ClassObject, ClassObjectData, SectionManager, SectionSettings, - SectionTypeSettings, + ClassObject, ClassObjectData, SectionManager, SectionSettings, SectionTypeSettings, }; let EntityType::Extended(extended) = entity else { @@ -169,16 +168,15 @@ impl Scene { ); if !valid_settings { let settings_handle = self.document.allocate_handle(); - let mut settings = ClassObject::new(ClassObjectData::SectionSettings( - SectionSettings { + let mut settings = + ClassObject::new(ClassObjectData::SectionSettings(SectionSettings { current_type: 4, types: vec![SectionTypeSettings { section_type: 4, generation: 17, ..SectionTypeSettings::default() }], - }, - )); + })); settings.handle = settings_handle; settings.owner = entity_handle; self.document @@ -223,9 +221,9 @@ impl Scene { if let Some(ObjectType::Dictionary(dictionary)) = self.document.objects.get_mut(&root) { - dictionary.entries.retain(|(name, _)| { - !name.eq_ignore_ascii_case("ACAD_SECTION_MANAGER") - }); + dictionary + .entries + .retain(|(name, _)| !name.eq_ignore_ascii_case("ACAD_SECTION_MANAGER")); dictionary.add_entry("ACAD_SECTION_MANAGER", handle); } handle @@ -256,10 +254,7 @@ impl Scene { let mut needs_geometry_bump = false; for entity in entities { - let affects_blocks = matches!( - &entity, - EntityType::Block(_) | EntityType::BlockEnd(_) - ); + let affects_blocks = matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_)); let handle = self.add_entity_internal(entity, false); handles.push(handle); if handle.is_null() { @@ -285,10 +280,7 @@ impl Scene { // Only block sentinels mutate a block definition and require rebuilding // the block cache. A top-level INSERT merely references an existing // definition, so adding it can patch just that new render handle. - let affects_blocks = matches!( - &entity, - EntityType::Block(_) | EntityType::BlockEnd(_) - ); + let affects_blocks = matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_)); // INSERT invalidates rendered block instances, but it does not mutate // the referenced block definition. Only block sentinels require a // structure image for undo; ordinary owner membership is intrinsic add @@ -363,8 +355,9 @@ impl Scene { // Delta-undo poison inputs (captured before the mutations below): an // add that also creates a new layer, adds a block, or inserts an image // definition mutates non-entity state a pure-entity delta can't undo. - let creates_layer = - self.is_recording_undo() && !layer.trim().is_empty() && !self.document.layers.contains(&layer); + let creates_layer = self.is_recording_undo() + && !layer.trim().is_empty() + && !self.document.layers.contains(&layer); self.ensure_layer(&layer); let app_ids: Vec = entity .common() @@ -374,9 +367,9 @@ impl Scene { .map(|record| record.application_name.clone()) .collect(); let creates_app_id = self.is_recording_undo() - && app_ids.iter().any(|name| { - !name.trim().is_empty() && !self.document.app_ids.contains(name) - }); + && app_ids + .iter() + .any(|name| !name.trim().is_empty() && !self.document.app_ids.contains(name)); for name in &app_ids { self.ensure_app_id(name); } @@ -412,14 +405,13 @@ impl Scene { if let Some(mut model) = mesh_seed { if let Some(entity) = self.document.get_entity(handle) { let color = self.render_style(entity).0; - let material = - crate::scene::model::material_model::resolve_material_with_base( - &self.document, - entity, - color, - None, - self.material_base_dir.as_deref(), - ); + let material = crate::scene::model::material_model::resolve_material_with_base( + &self.document, + entity, + color, + None, + self.material_base_dir.as_deref(), + ); material.apply_to_with_face_overrides( &mut model, &self.document, @@ -539,13 +531,8 @@ impl Scene { // Replacing (or becoming) a block sentinel forces a full block-cache // rebuild. INSERT edits (including retargeting to another existing // definition) only change that top-level render handle. - let affects_blocks = matches!( - existing, - EntityType::Block(_) | EntityType::BlockEnd(_) - ) || matches!( - &entity, - EntityType::Block(_) | EntityType::BlockEnd(_) - ); + let affects_blocks = matches!(existing, EntityType::Block(_) | EntityType::BlockEnd(_)) + || matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_)); // A plugin edit may retarget the entity to a novel layer; register it // so the edited entity keeps that layer on save instead of collapsing @@ -627,14 +614,13 @@ impl Scene { if let Some(mut model) = mesh_seed { if let Some(entity) = self.document.get_entity(handle) { let color = self.render_style(entity).0; - let material = - crate::scene::model::material_model::resolve_material_with_base( - &self.document, - entity, - color, - None, - self.material_base_dir.as_deref(), - ); + let material = crate::scene::model::material_model::resolve_material_with_base( + &self.document, + entity, + color, + None, + self.material_base_dir.as_deref(), + ); material.apply_to_with_face_overrides( &mut model, &self.document, @@ -770,15 +756,14 @@ impl Scene { _ => None, }) .collect(); - let entries: Vec<(Handle, std::sync::Arc, [f32; 4], bool)> = - mesh_entities - .into_iter() - .map(|(handle, entity)| { - let color = self.render_style(entity.as_ref()).0; - let top_level = layout_blocks.contains(&entity.common().owner_handle); - (handle, entity, color, top_level) - }) - .collect(); + let entries: Vec<(Handle, std::sync::Arc, [f32; 4], bool)> = mesh_entities + .into_iter() + .map(|(handle, entity)| { + let color = self.render_style(entity.as_ref()).0; + let top_level = layout_blocks.contains(&entity.common().owner_handle); + (handle, entity, color, top_level) + }) + .collect(); let facet_res = self.document.header.facet_resolution; let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_res); @@ -1119,16 +1104,13 @@ impl Scene { self.belongs_to_visible_block(handle, c.owner_handle, target_block) }) .flat_map(|(&handle, model)| { - let contextual = self - .document - .get_entity(handle) - .map(|entity| { - crate::scene::annotative::entity_for_annotation_context( - &self.document, - entity, - annotation_scale_handle, - ) - }); + let contextual = self.document.get_entity(handle).map(|entity| { + crate::scene::annotative::entity_for_annotation_context( + &self.document, + entity, + annotation_scale_handle, + ) + }); let entity = contextual.as_deref(); let mut m = match entity { Some(EntityType::Hatch(dxf)) @@ -1196,10 +1178,7 @@ impl Scene { ), index, ), - other => ( - crate::scene::convert::tess_util::aci_to_rgba(&other), - 0, - ), + other => (crate::scene::convert::tess_util::aci_to_rgba(&other), 0), }; b.color = bg_color; b.aci = bg_aci; @@ -1218,9 +1197,7 @@ impl Scene { m.scale = dxf.pattern_scale as f32; } model::hatch_model::HatchPattern::Gradient { - angle_deg, - shift, - .. + angle_deg, shift, .. } => { *angle_deg = dxf.gradient_color.angle.to_degrees() as f32; *shift = dxf.gradient_color.shift as f32; @@ -1351,7 +1328,10 @@ impl Scene { .iter() .copied() .filter(|&handle| { - matches!(self.document.get_entity(handle), Some(EntityType::Insert(_))) + matches!( + self.document.get_entity(handle), + Some(EntityType::Insert(_)) + ) }) .collect(); if targets.is_empty() { @@ -1421,8 +1401,7 @@ impl Scene { return true; } if self.object_isolation.hides(common.handle) - || (!include_preview_hidden - && self.preview_hidden.contains(&common.handle)) + || (!include_preview_hidden && self.preview_hidden.contains(&common.handle)) { return false; } @@ -1462,10 +1441,7 @@ impl Scene { } let style = context.style_for(&self.document, entity); let preserve_white_mask = source_hatch.is_solid - && matches!( - source_hatch.common.color, - acadrust::types::Color::Index(7) - ); + && matches!(source_hatch.common.color, acadrust::types::Color::Index(7)); let color = if preserve_white_mask { style.0 } else { @@ -1504,9 +1480,15 @@ impl Scene { } let matrix = &context.transform.matrix.m; let linear = [ - matrix[0][0].to_bits(), matrix[0][1].to_bits(), matrix[0][2].to_bits(), - matrix[1][0].to_bits(), matrix[1][1].to_bits(), matrix[1][2].to_bits(), - matrix[2][0].to_bits(), matrix[2][1].to_bits(), matrix[2][2].to_bits(), + matrix[0][0].to_bits(), + matrix[0][1].to_bits(), + matrix[0][2].to_bits(), + matrix[1][0].to_bits(), + matrix[1][1].to_bits(), + matrix[1][2].to_bits(), + matrix[2][0].to_bits(), + matrix[2][1].to_bits(), + matrix[2][2].to_bits(), ]; let key = ( source_hatch.common.handle.value(), @@ -1525,12 +1507,10 @@ impl Scene { let source_id = *hatch_sources .entry(key) .or_insert_with(crate::scene::model::instance_model::next_source_id); - model.render_instance = Some( - crate::scene::model::instance_model::RenderInstance { - source_id, - translation: [matrix[0][3], matrix[1][3], matrix[2][3]], - }, - ); + model.render_instance = Some(crate::scene::model::instance_model::RenderInstance { + source_id, + translation: [matrix[0][3], matrix[1][3], matrix[2][3]], + }); models.push(model); }, ); @@ -1598,48 +1578,37 @@ impl Scene { }; let mut wipeout = source.clone(); if context.is_instanced() { - wipeout.insertion_point = - context.transform.apply(source.insertion_point); - wipeout.u_vector = - context.transform.apply_rotation(source.u_vector); - wipeout.v_vector = - context.transform.apply_rotation(source.v_vector); + wipeout.insertion_point = context.transform.apply(source.insertion_point); + wipeout.u_vector = context.transform.apply_rotation(source.u_vector); + wipeout.v_vector = context.transform.apply_rotation(source.v_vector); } let Some(mut fill_plane) = Self::wipeout_fill_plane(&wipeout) else { return; }; - let (world_origin, mut boundary) = - Self::wipeout_boundary_2d(&wipeout); + let (world_origin, mut boundary) = Self::wipeout_boundary_2d(&wipeout); for clip in &context.clips { let clip: Vec<[f32; 2]> = clip .iter() .map(|point| [point[0] as f32, point[1] as f32]) .collect(); - boundary = pick::xclip::clip_hatch_boundary( - &boundary, - world_origin, - &clip, - ); + boundary = pick::xclip::clip_hatch_boundary(&boundary, world_origin, &clip); } if boundary.len() < 3 { return; } if !context.clips.is_empty() { - let Some(local) = Self::wipeout_boundary_at_xy( - fill_plane.0, - world_origin, - &boundary, - ) else { + let Some(local) = + Self::wipeout_boundary_at_xy(fill_plane.0, world_origin, &boundary) + else { return; }; fill_plane.1 = local; } - let selection_handle = - if tint_insert_selection && context.is_instanced() { - context.root_handle - } else { - source.common.handle - }; + let selection_handle = if tint_insert_selection && context.is_instanced() { + context.root_handle + } else { + source.common.handle + }; let color = if self.selected.contains(&selection_handle) { [0.15, 0.55, 1.00, 0.35] } else { @@ -1648,9 +1617,15 @@ impl Scene { let render_instance = if context.is_instanced() { let matrix = &context.transform.matrix.m; let linear = [ - matrix[0][0].to_bits(), matrix[0][1].to_bits(), matrix[0][2].to_bits(), - matrix[1][0].to_bits(), matrix[1][1].to_bits(), matrix[1][2].to_bits(), - matrix[2][0].to_bits(), matrix[2][1].to_bits(), matrix[2][2].to_bits(), + matrix[0][0].to_bits(), + matrix[0][1].to_bits(), + matrix[0][2].to_bits(), + matrix[1][0].to_bits(), + matrix[1][1].to_bits(), + matrix[1][2].to_bits(), + matrix[2][0].to_bits(), + matrix[2][1].to_bits(), + matrix[2][2].to_bits(), ]; let key = ( source.common.handle.value(), @@ -1661,9 +1636,9 @@ impl Scene { .collect::>(), color.map(f32::to_bits), ); - let source_id = *wipeout_sources.entry(key).or_insert_with( - crate::scene::model::instance_model::next_source_id, - ); + let source_id = *wipeout_sources + .entry(key) + .or_insert_with(crate::scene::model::instance_model::next_source_id); Some(crate::scene::model::instance_model::RenderInstance { source_id, translation: [matrix[0][3], matrix[1][3], matrix[2][3]], @@ -1692,8 +1667,7 @@ impl Scene { angle_offset: 0.0, scale: 1.0, world_origin, - draw_depth: context - .draw_depth(source.common.handle, depth_map.as_ref()), + draw_depth: context.draw_depth(source.common.handle, depth_map.as_ref()), }); }, ); @@ -1708,7 +1682,11 @@ impl Scene { ) -> ([f64; 2], Vec<[f32; 2]>) { let origin = [wo.insertion_point.x, wo.insertion_point.y]; let plane = cadkernel::space::Plane::from_axes( - [wo.insertion_point.x, wo.insertion_point.y, wo.insertion_point.z], + [ + wo.insertion_point.x, + wo.insertion_point.y, + wo.insertion_point.z, + ], [wo.u_vector.x, wo.u_vector.y, wo.u_vector.z], [wo.v_vector.x, wo.v_vector.y, wo.v_vector.z], ); @@ -1744,12 +1722,7 @@ impl Scene { let mut clip: Vec<[f64; 2]> = wo .clip_boundary_vertices .iter() - .map(|point| { - [ - point.x + wo.size.x * 0.5, - wo.size.y * 0.5 - point.y, - ] - }) + .map(|point| [point.x + wo.size.x * 0.5, wo.size.y * 0.5 - point.y]) .collect(); if clip.last() != clip.first() { clip.push(clip[0]); @@ -1793,11 +1766,7 @@ impl Scene { world_origin: [f64; 2], boundary: &[[f32; 2]], ) -> Option> { - let plane = cadkernel::space::Plane::from_axes( - plane.origin, - plane.x_axis, - plane.y_axis, - ); + let plane = cadkernel::space::Plane::from_axes(plane.origin, plane.x_axis, plane.y_axis); boundary .iter() .map(|point| { @@ -1815,10 +1784,7 @@ impl Scene { .collect() } - pub(crate) fn hatch_model_from_dxf( - dxf: &DxfHatch, - color: [f32; 4], - ) -> Option { + pub(crate) fn hatch_model_from_dxf(dxf: &DxfHatch, color: [f32; 4]) -> Option { let normal = (dxf.normal.x, dxf.normal.y, dxf.normal.z); // Build the boundary in f64 first so the precision-preserving // origin computation below sees full WCS precision. We only cast @@ -1849,9 +1815,7 @@ impl Scene { let mut edge_polys: Vec> = Vec::new(); for edge in &path.edges { if let Some(curve) = crate::entities::hatch::edge_curve(edge) { - edge_polys.push( - curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE), - ); + edge_polys.push(curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE)); } } let mut local_ring = chain_path_edges(edge_polys); @@ -1902,11 +1866,7 @@ impl Scene { local_boundary.push([f32::NAN, f32::NAN]); } boundary.extend(ring); - local_boundary.extend( - local_ring - .into_iter() - .map(|[x, y]| [x as f32, y as f32]), - ); + local_boundary.extend(local_ring.into_iter().map(|[x, y]| [x as f32, y as f32])); boundary_exterior.push(depth == 0); boundary_sources.push(sources); } @@ -1941,18 +1901,19 @@ impl Scene { // lines. The stored offset is the authoritative world-unit spacing, so // the resulting families are already final: no pattern_scale / angle // is re-applied (see `prebaked` below and the HatchModel fields). - let prebaked = !dxf.is_solid - && !dxf.gradient_color.is_enabled() - && !dxf.pattern.lines.is_empty(); + let prebaked = + !dxf.is_solid && !dxf.gradient_color.is_enabled() && !dxf.pattern.lines.is_empty(); // The gradient's first stop is the fill's start colour (not the // entity colour); capture it so the HatchModel draws stop-0 → stop-1. let mut gradient_color1: Option<[f32; 4]> = None; let mut pattern = if dxf.gradient_color.is_enabled() { let stop = |i: usize| { - dxf.gradient_color.colors.get(i).and_then(|e| e.color.rgb()).map( - |(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0], - ) + dxf.gradient_color + .colors + .get(i) + .and_then(|e| e.color.rgb()) + .map(|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]) }; gradient_color1 = stop(0); let color1 = gradient_color1.unwrap_or(color); @@ -1975,7 +1936,11 @@ impl Scene { model::hatch_model::HatchPattern::Solid } else if prebaked { model::hatch_model::HatchPattern::Pattern( - dxf.pattern.lines.iter().map(family_from_stored_line).collect(), + dxf.pattern + .lines + .iter() + .map(family_from_stored_line) + .collect(), ) } else { let pat_name = &dxf.pattern.name; @@ -2038,10 +2003,18 @@ impl Scene { let mut max_y = f64::NEG_INFINITY; for &[x, y] in &boundary { if x.is_finite() && y.is_finite() { - if x < min_x { min_x = x; } - if y < min_y { min_y = y; } - if x > max_x { max_x = x; } - if y > max_y { max_y = y; } + if x < min_x { + min_x = x; + } + if y < min_y { + min_y = y; + } + if x > max_x { + max_x = x; + } + if y > max_y { + max_y = y; + } } } let world_origin = if min_x.is_finite() && min_y.is_finite() { @@ -2129,8 +2102,16 @@ impl Scene { color: gradient_color1.unwrap_or(color), aci: 0, line_weight_px: 1.0, - angle_offset: if prebaked { 0.0 } else { dxf.pattern_angle as f32 }, - scale: if prebaked { 1.0 } else { dxf.pattern_scale as f32 }, + angle_offset: if prebaked { + 0.0 + } else { + dxf.pattern_angle as f32 + }, + scale: if prebaked { + 1.0 + } else { + dxf.pattern_scale as f32 + }, world_origin, draw_depth: 0.0, }) @@ -2168,16 +2149,13 @@ impl Scene { /// at load, so a pattern-scale / background / boundary edit stays /// invisible until the cached model is refreshed (#415). pub fn refresh_fill_model(&mut self, handle: Handle) { - let contextual = self - .document - .get_entity(handle) - .map(|entity| { - crate::scene::annotative::entity_for_annotation_context( - &self.document, - entity, - self.displayed_annotation_scale_handle(), - ) - }); + let contextual = self.document.get_entity(handle).map(|entity| { + crate::scene::annotative::entity_for_annotation_context( + &self.document, + entity, + self.displayed_annotation_scale_handle(), + ) + }); let new_model = match contextual.as_deref() { Some(EntityType::Hatch(dxf)) => { let color = convert::tess_util::aci_to_rgba(&dxf.common.color); @@ -2213,7 +2191,7 @@ impl Scene { e, self.displayed_annotation_scale_handle(), ) - .into_owned(), + .into_owned(), )), EntityType::Solid(s) => Some((s.common.handle, e.clone())), _ => None, @@ -2315,7 +2293,8 @@ impl Scene { // Incremental (post-xref) pass: leave already-tessellated // host solids untouched, only build the newly merged ones. if incremental - && (self.meshes.contains_key(&handle) || self.block_meshes.contains_key(&handle)) + && (self.meshes.contains_key(&handle) + || self.block_meshes.contains_key(&handle)) { return None; } @@ -2363,7 +2342,11 @@ impl Scene { &self.document, &entity, ); - let mesh = if top_level { offset_mesh_lod_set(mesh) } else { mesh }; + let mesh = if top_level { + offset_mesh_lod_set(mesh) + } else { + mesh + }; (handle, mesh, top_level) }) }) @@ -2478,18 +2461,18 @@ impl Scene { } else { // Otherwise reconstruct every ring from render offsets. let reconstructed_wcs: Vec<[f64; 2]> = if model.boundary_wcs.is_none() { - let [wx, wy] = model.world_origin; - model - .boundary - .iter() - .map(|&[x, y]| { - if x.is_finite() && y.is_finite() { - [x as f64 + wx, y as f64 + wy] - } else { - [f64::NAN, f64::NAN] - } - }) - .collect() + let [wx, wy] = model.world_origin; + model + .boundary + .iter() + .map(|&[x, y]| { + if x.is_finite() && y.is_finite() { + [x as f64 + wx, y as f64 + wy] + } else { + [f64::NAN, f64::NAN] + } + }) + .collect() } else { Vec::new() }; @@ -2502,31 +2485,31 @@ impl Scene { let mut first = true; let mut ring_index = 0usize; let mut push_ring = |r: &mut Vec, is_outer: bool, index: usize| { - if !r.is_empty() { - let edge = PolylineEdge::new(std::mem::take(r), true); - let handles: Vec<_> = model - .boundary_sources - .as_deref() - .and_then(|sources| sources.get(index)) - .into_iter() - .flatten() - .copied() - .filter(|handle| handle.is_valid()) - .collect(); - let mut bits = 0; - if is_outer { - bits |= acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits(); - bits |= acadrust::entities::hatch::BoundaryPathFlags::EXTERNAL.bits(); + if !r.is_empty() { + let edge = PolylineEdge::new(std::mem::take(r), true); + let handles: Vec<_> = model + .boundary_sources + .as_deref() + .and_then(|sources| sources.get(index)) + .into_iter() + .flatten() + .copied() + .filter(|handle| handle.is_valid()) + .collect(); + let mut bits = 0; + if is_outer { + bits |= acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits(); + bits |= acadrust::entities::hatch::BoundaryPathFlags::EXTERNAL.bits(); + } + let mut path = BoundaryPath::with_flags( + acadrust::entities::hatch::BoundaryPathFlags::from_bits(bits), + ); + path.add_edge(BoundaryEdge::Polyline(edge)); + for handle in handles { + path.add_boundary_handle(handle); + } + dxf.paths.push(path); } - let mut path = BoundaryPath::with_flags( - acadrust::entities::hatch::BoundaryPathFlags::from_bits(bits), - ); - path.add_edge(BoundaryEdge::Polyline(edge)); - for handle in handles { - path.add_boundary_handle(handle); - } - dxf.paths.push(path); - } }; for &[x, y] in wcs { if x.is_finite() && y.is_finite() { @@ -2580,28 +2563,20 @@ impl Scene { let family_angle = (family.angle_deg as f64).to_radians(); let angle = family_angle + rotation; let (family_sin, family_cos) = family_angle.sin_cos(); - let local_offset_x = family.dx as f64 * family_cos - - family.dy as f64 * family_sin; - let local_offset_y = family.dx as f64 * family_sin - + family.dy as f64 * family_cos; + let local_offset_x = family.dx as f64 * family_cos - family.dy as f64 * family_sin; + let local_offset_y = family.dx as f64 * family_sin + family.dy as f64 * family_cos; let base_x = family.x0 as f64 * pattern_scale; let base_y = family.y0 as f64 * pattern_scale; pattern.lines.push(acadrust::entities::HatchPatternLine { angle, base_point: Vector2::new( - pattern_origin[0] - + base_x * rotation_cos - - base_y * rotation_sin, - pattern_origin[1] - + base_x * rotation_sin - + base_y * rotation_cos, + pattern_origin[0] + base_x * rotation_cos - base_y * rotation_sin, + pattern_origin[1] + base_x * rotation_sin + base_y * rotation_cos, ), offset: Vector2::new( - (local_offset_x * rotation_cos - - local_offset_y * rotation_sin) + (local_offset_x * rotation_cos - local_offset_y * rotation_sin) * pattern_scale, - (local_offset_x * rotation_sin - + local_offset_y * rotation_cos) + (local_offset_x * rotation_sin + local_offset_y * rotation_cos) * pattern_scale, ), dash_lengths: family @@ -2642,13 +2617,13 @@ impl Scene { dxf.gradient_color.is_single_color = false; // Linear has no INV name in the standard set: persist an inverted // linear by swapping the colour stops instead. - let (c0, c1) = - if *invert && matches!(kind, crate::scene::model::hatch_model::GradientKind::Linear) - { - (*color2, model.color) - } else { - (model.color, *color2) - }; + let (c0, c1) = if *invert + && matches!(kind, crate::scene::model::hatch_model::GradientKind::Linear) + { + (*color2, model.color) + } else { + (model.color, *color2) + }; dxf.gradient_color.colors = vec![ acadrust::entities::hatch::GradientColorEntry { value: 0.0, @@ -2703,7 +2678,7 @@ impl Scene { // resetting them, a "new"/"open" (e.g. via automation, which reuses // this same reset) would leak the previous document's constraints // and named parameters into the fresh one. - self.sketch_constraints.clear(); + self.parametric_constraints.clear(); self.named_parameters = crate::scene::named_parameters::ParameterTable::new(); self.bump_geometry(); } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d36fef59f..928cc2844 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -29,17 +29,16 @@ pub enum BlockScalePolicy { mod boundary; mod camera_ops; pub(crate) mod centerline; -pub(crate) mod dimension_assoc; pub(crate) mod centermark; +pub(crate) mod dimension_assoc; mod dwg_native_constraints; mod entity; mod group_layer; mod layout; mod limits; mod modify; -pub mod named_parameters; -mod named_parameters_persist; mod mspace; +pub mod named_parameters; mod page_setup; mod paper; mod preview; @@ -47,22 +46,20 @@ mod project; mod scene_markers; mod selection; pub(crate) use selection::pe_url_of; -pub mod sketch_constraints; -mod sketch_persist; -mod sketch_solve; +pub mod parametric_constraints; +mod parametric_solve; pub(crate) use boundary::{ - boundary_entities, boundary_entities_from_sources, boundary_faces, - boundary_polyline_entities, exact_hatch_paths, hatch_boundary_rings, hatch_path_directions, - hatch_path_ring, ring_source_handles, separated_hatch_path_groups, BoundarySource, + boundary_entities, boundary_entities_from_sources, boundary_faces, boundary_polyline_entities, + exact_hatch_paths, hatch_boundary_rings, hatch_path_directions, hatch_path_ring, + ring_source_handles, separated_hatch_path_groups, BoundarySource, }; // Parallel tessellation free functions live in `convert::tess` (alongside the // other tessellation code); re-exported here so this root and sibling topic // modules (each does `use super::*`) keep referencing them unqualified. pub(crate) use convert::tess::{ - entity_aabb, entity_world_aabb_f64, is_unindexable_entity, - tessellate_entity_dim_text, + entity_aabb, entity_world_aabb_f64, is_unindexable_entity, tessellate_entity_dim_text, }; /// Keep construction-history curves in the same per-entity tessellation memo @@ -82,9 +79,17 @@ pub(crate) fn tessellate_entity( paper_space: bool, ) -> Vec { let mut wires = convert::tess::tessellate_entity( - document, selected, active_viewport, bg_color, anno_scale, - annotation_scale_handle, entity, block_cache, view_aabb, - world_per_pixel, paper_space, + document, + selected, + active_viewport, + bg_color, + anno_scale, + annotation_scale_handle, + entity, + block_cache, + view_aabb, + world_per_pixel, + paper_space, ); if matches!(entity, EntityType::Solid3D(_) | EntityType::Surface(_)) { let handle = entity.common().handle; @@ -105,11 +110,16 @@ pub(crate) fn tessellate_entity( view::render::render_style_for_viewport(document, &source, active_viewport); for boundary in wires { for curve in boundary { - let points = curve.tessellate(64.0 / std::f64::consts::TAU) - .into_iter().map(|point| plane.point_at(point)); + let points = curve + .tessellate(64.0 / std::f64::consts::TAU) + .into_iter() + .map(|point| plane.point_at(point)); let (points, points_low) = convert::tessellate::points_to_ds(points); let mut wire = WireModel::solid( - handle.value().to_string(), points, color, selected.contains(&handle), + handle.value().to_string(), + points, + color, + selected.contains(&handle), ); wire.points_low = points_low; wire.line_weight_px = line_weight_px; @@ -120,9 +130,17 @@ pub(crate) fn tessellate_entity( boundaries } else { convert::tess::tessellate_entity( - document, selected, active_viewport, bg_color, anno_scale, - annotation_scale_handle, &source, block_cache, view_aabb, - world_per_pixel, paper_space, + document, + selected, + active_viewport, + bg_color, + anno_scale, + annotation_scale_handle, + &source, + block_cache, + view_aabb, + world_per_pixel, + paper_space, ) }; for wire in &mut construction { @@ -334,15 +352,18 @@ pub struct UndoRecording { /// by the command. `None` denotes a newly-created object. object_before: HashMap>, object_order: Vec, - /// First-touch before-images of every sketch-constraint scope a command + /// First-touch before-images of every parametric-constraint scope a command /// changed (e.g. ERASE removing an entity's constraints along with it via - /// `refresh_sketch_constraints`'s deletion policy). `sketch_constraints` + /// `refresh_parametric_constraints`'s deletion policy). `parametric_constraints` /// lives on `Scene`, not in `document`/`document.objects`, so neither /// directory above ever sees this change — it needs its own directory. /// Unlike the two above, there is no "didn't exist before" case: a /// scope's `Vec` entry, once created, is never removed. - sketch_constraints_before: HashMap, - sketch_constraints_order: Vec, + parametric_constraints_before: HashMap< + parametric_constraints::ParametricScope, + parametric_constraints::ParametricConstraintSet, + >, + parametric_constraints_order: Vec, named_parameters_before: Option, poisoned: bool, } @@ -357,21 +378,24 @@ impl UndoRecording { pub fn is_empty(&self) -> bool { self.order.is_empty() && self.object_order.is_empty() - && self.sketch_constraints_order.is_empty() + && self.parametric_constraints_order.is_empty() && self.named_parameters_before.is_none() } /// Consume the recording directories in deterministic first-touch /// order. A `None` image means the entity/object was added by the - /// command; every sketch-constraint scope's before-image is a real - /// `SketchConstraintSet` (its `Vec` entry, once created, is never + /// command; every parametric-constraint scope's before-image is a real + /// `ParametricConstraintSet` (its `Vec` entry, once created, is never /// removed, so there is no "didn't exist before" case there). pub fn into_recorded_images( mut self, ) -> ( Vec<(Handle, Option>)>, Vec<(Handle, Option)>, - Vec<(sketch_constraints::SketchScope, sketch_constraints::SketchConstraintSet)>, + Vec<( + parametric_constraints::ParametricScope, + parametric_constraints::ParametricConstraintSet, + )>, Option, ) { let entities = self @@ -384,12 +408,21 @@ impl UndoRecording { .drain(..) .map(|h| (h, self.object_before.remove(&h).flatten())) .collect(); - let sketch_constraints = self - .sketch_constraints_order + let parametric_constraints = self + .parametric_constraints_order .drain(..) - .filter_map(|scope| self.sketch_constraints_before.remove(&scope).map(|before| (scope, before))) + .filter_map(|scope| { + self.parametric_constraints_before + .remove(&scope) + .map(|before| (scope, before)) + }) .collect(); - (entities, objects, sketch_constraints, self.named_parameters_before) + ( + entities, + objects, + parametric_constraints, + self.named_parameters_before, + ) } /// Entity-only convenience used by the focused Scene delta tests. @@ -897,14 +930,14 @@ fn build_derived_caches_impl( EntityType::RasterImage(img) => { ImageModel::from_raster_image(img).map(|m| (handle, m)) } - EntityType::Ole2Frame(ole) => ImageModel::from_ole2frame(ole).map(|m| (handle, m)), - EntityType::Underlay(u) => match doc.objects.get(&u.definition_handle) { - Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) => { - ImageModel::from_underlay(u, def).map(|m| (handle, m)) - } + EntityType::Ole2Frame(ole) => ImageModel::from_ole2frame(ole).map(|m| (handle, m)), + EntityType::Underlay(u) => match doc.objects.get(&u.definition_handle) { + Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) => { + ImageModel::from_underlay(u, def).map(|m| (handle, m)) + } + _ => None, + }, _ => None, - }, - _ => None, }; let done = detail_done.fetch_add(1, Ordering::Relaxed) + 1; if done & 0xff == 0 || done == detail_total { @@ -918,8 +951,7 @@ fn build_derived_caches_impl( // Top-level (layout-owned) solids are offset into the render frame; block // definition solids keep block-local coords for per-INSERT instancing. (#123) let facet_res = doc.header.facet_resolution; - let chordal_deflection = - crate::entities::solid3d::display_deflection(&doc.header, facet_res); + let chordal_deflection = crate::entities::solid3d::display_deflection(&doc.header, facet_res); let isolines = doc.header.isolines.max(0) as usize; // Real layout blocks come from the Layout objects' block_record handles — // `BlockRecord::is_layout()` is unreliable here (it flags ordinary blocks). @@ -955,17 +987,13 @@ fn build_derived_caches_impl( isolines, ) .map(|mut mesh| { - material.apply_to_with_face_overrides( - &mut mesh, - doc, - material_base_dir, - ); - crate::scene::model::visual_style_model::apply_mesh_visual_style( - &mut mesh, - doc, - e, - ); - let mesh = if top_level { offset_mesh_lod_set(mesh) } else { mesh }; + material.apply_to_with_face_overrides(&mut mesh, doc, material_base_dir); + crate::scene::model::visual_style_model::apply_mesh_visual_style(&mut mesh, doc, e); + let mesh = if top_level { + offset_mesh_lod_set(mesh) + } else { + mesh + }; (handle, mesh, top_level) }); let done = detail_done.fetch_add(1, Ordering::Relaxed) + 1; @@ -1477,7 +1505,10 @@ fn transform_block_mesh_lod_set( instance_transform: Some(*xform), instance_handle: None, instance_color: set.display_color(), - instance_aabb: bounds.iter().all(|value| value.is_finite()).then_some(bounds), + instance_aabb: bounds + .iter() + .all(|value| value.is_finite()) + .then_some(bounds), } } @@ -1801,10 +1832,8 @@ pub struct Scene { /// filtered variants. Viewports sharing a frozen set share one entry (like /// the resident wire set). Empty for a viewport with no frozen layers (it /// reuses the unfiltered `*_arc` sets directly). - frozen_hatch_cache: - RefCell>)>>, - frozen_wipeout_cache: - RefCell>)>>, + frozen_hatch_cache: RefCell>)>>, + frozen_wipeout_cache: RefCell>)>>, frozen_image_cache: RefCell>)>>, frozen_mesh_cache: RefCell>)>>, /// Viewports that carry layer color/alpha/linetype/lineweight overrides. @@ -1830,13 +1859,9 @@ pub struct Scene { paper_sheet_render_cache: RefCell>, display_plot_style_cache: RefCell>>>, - styled_wire_cache: RefCell< - HashMap<(u64, String), (u64, Arc>)>, - >, - styled_hatch_cache: - RefCell>>>, - styled_wire_fill_cache: - RefCell>>>, + styled_wire_cache: RefCell>)>>, + styled_hatch_cache: RefCell>>>, + styled_wire_fill_cache: RefCell>>>, /// Per-viewport projected wire cache for paper-space content viewports. /// Stores projected + clipped wires in paper-space coordinates. /// Maps vp_handle → (geometry_epoch, Vec). @@ -1924,16 +1949,15 @@ pub struct Scene { /// Conservative association hint: unknown until scanned, then updated from /// changed entities. Retaining `true` after deletion only costs an extra scan. has_associative_centers: std::cell::Cell>, - /// Persistent parametric constraint sets, one per sketch scope. - /// Materialized into scoped XRecords during save and restored on open. - pub(crate) sketch_constraints: Vec, + /// Runtime parametric constraint sets decoded from standard graph scopes. + pub(crate) parametric_constraints: Vec, /// Session-only visibility overrides for constraint glyphs. - hidden_sketch_constraints: HashSet<( - sketch_constraints::SketchScope, - sketch_constraints::ConstraintId, + hidden_parametric_constraints: HashSet<( + parametric_constraints::ParametricScope, + parametric_constraints::ConstraintId, )>, - /// Document-wide named-parameter and expression table. - /// Persisted through a single drawing XRecord. + /// Document-wide named-parameter and expression table decoded from + /// standard associative variables. pub(crate) named_parameters: named_parameters::ParameterTable, /// Tessellated block definitions in block-local coords, keyed by render /// background and block epoch. Model and Paper adapt black/white colours @@ -2070,7 +2094,11 @@ fn section_frame( ); let vertical = (raw_vertical - tangent * raw_vertical.dot(tangent)).try_normalize()?; let base_view = vertical.cross(tangent).try_normalize()?; - let viewing = if data.flags & 4 != 0 { base_view } else { -base_view }; + let viewing = if data.flags & 4 != 0 { + base_view + } else { + -base_view + }; Some((origin, tangent, vertical, viewing)) } @@ -2100,14 +2128,9 @@ fn section_plane( [matrix[2][0], matrix[2][1], matrix[2][2]], ); let inverse = linear.inverse()?; - let translation = acadrust::types::Vector3::new( - matrix[0][3], - matrix[1][3], - matrix[2][3], - ); - let origin = inverse.transform_point( - acadrust::types::Vector3::new(origin.x, origin.y, origin.z) - translation, - ); + let translation = acadrust::types::Vector3::new(matrix[0][3], matrix[1][3], matrix[2][3]); + let origin = inverse + .transform_point(acadrust::types::Vector3::new(origin.x, origin.y, origin.z) - translation); let tangent = inverse.transform_point(acadrust::types::Vector3::new( tangent.x, tangent.y, tangent.z, )); @@ -2248,8 +2271,8 @@ impl Scene { layout_type_names_cache: RefCell::new(None), dependency_index_cache: RefCell::new(None), associative_hatch_source_cache: RefCell::new(None), - sketch_constraints: Vec::new(), - hidden_sketch_constraints: HashSet::default(), + parametric_constraints: Vec::new(), + hidden_parametric_constraints: HashSet::default(), named_parameters: named_parameters::ParameterTable::new(), has_associative_centers: std::cell::Cell::new(None), block_defn_cache: RefCell::new(HashMap::default()), @@ -2336,7 +2359,9 @@ impl Scene { } else { self.paper_bg_color }; - let mut key = annotation_scale_handle.map(|handle| handle.value()).unwrap_or(0); + let mut key = annotation_scale_handle + .map(|handle| handle.value()) + .unwrap_or(0); for component in bg { key = key.rotate_left(13) ^ component.to_bits() as u64; } @@ -2432,8 +2457,7 @@ impl Scene { /// rebuild. Every `geometry_epoch` bump must call this exactly once so the /// journal never has a gap (a gap would let a consumer serve stale geometry). fn push_geometry_delta(&self, epoch: u64, changes: Vec<(Handle, ChangeKind)>, full: bool) { - let pending_categories = - std::mem::take(&mut *self.pending_removed_categories.borrow_mut()); + let pending_categories = std::mem::take(&mut *self.pending_removed_categories.borrow_mut()); let removed_categories = if full { HashMap::default() } else { @@ -2681,11 +2705,7 @@ impl Scene { /// Record one document.objects entry before its first mutation. This keeps /// Group erase and RasterImage add on targeted history rather than forcing /// a clone of all document structure. - pub(crate) fn record_undo_object_before( - &mut self, - handle: Handle, - before: Option, - ) { + pub(crate) fn record_undo_object_before(&mut self, handle: Handle, before: Option) { if let Some(rec) = self.undo_recording.as_mut() { if !rec.object_before.contains_key(&handle) { rec.object_order.push(handle); @@ -2694,22 +2714,22 @@ impl Scene { } } - /// Record one sketch-constraint scope's whole-set image before its first + /// Record one parametric-constraint scope's whole-set image before its first /// mutation within the open recording (first touch wins) — e.g. ERASE /// about to remove some of a scope's constraints via - /// `refresh_sketch_constraints`'s deletion policy. `sketch_constraints` + /// `refresh_parametric_constraints`'s deletion policy. `parametric_constraints` /// lives on `Scene`, outside `document`/`document.objects`, so neither /// `record_undo_before` nor `record_undo_object_before` ever sees this /// change on their own. - pub(crate) fn record_undo_sketch_constraints_before( + pub(crate) fn record_undo_parametric_constraints_before( &mut self, - scope: sketch_constraints::SketchScope, - before: sketch_constraints::SketchConstraintSet, + scope: parametric_constraints::ParametricScope, + before: parametric_constraints::ParametricConstraintSet, ) { if let Some(rec) = self.undo_recording.as_mut() { - if !rec.sketch_constraints_before.contains_key(&scope) { - rec.sketch_constraints_order.push(scope); - rec.sketch_constraints_before.insert(scope, before); + if !rec.parametric_constraints_before.contains_key(&scope) { + rec.parametric_constraints_order.push(scope); + rec.parametric_constraints_before.insert(scope, before); } } } @@ -2844,13 +2864,16 @@ impl Scene { changes.push(change); } } - if !self.sketch_constraints.is_empty() { - for change in self.refresh_sketch_constraints(&changes) { + if !self.parametric_constraints.is_empty() { + for change in self.refresh_parametric_constraints(&changes) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } } } + if !self.parametric_constraints.is_empty() || !self.named_parameters.is_empty() { + self.sync_native_parametric_graph(); + } if !changes.is_empty() { self.refresh_dependency_index_for_changes(&changes); } @@ -2904,10 +2927,7 @@ impl Scene { /// Removed entities may no longer have a live slot, so fall back to the /// BlockRecord membership list, which deliberately retains their handles /// for symmetric undo/redo restoration. - pub(crate) fn changes_touch_block_definition( - &self, - changes: &[(Handle, ChangeKind)], - ) -> bool { + pub(crate) fn changes_touch_block_definition(&self, changes: &[(Handle, ChangeKind)]) -> bool { let layout_blocks: HashSet = self .document .objects @@ -2998,8 +3018,12 @@ impl Scene { } else if self.current_layout != "Model" { // Active in paper: entered content vp, else the sheet (D6(a)). match self.active_viewport { - Some(h) => { set.insert(0x3000_0000_0000_0000 | h.value()); } - None => { set.insert(0x2000_0000_0000_0000); } + Some(h) => { + set.insert(0x3000_0000_0000_0000 | h.value()); + } + None => { + set.insert(0x2000_0000_0000_0000); + } } } else { // Active in Model: the active tile (always resolvable). @@ -3159,15 +3183,14 @@ impl Scene { ), _ => ([self.document.header.isolines.max(0) as usize; 2], false), }; - let (mut set, wires, center) = - crate::scene::model::solid_model::display_from_solid( - solid, - color, - facet_resolution, - chordal_deflection, - isolines, - planar_isolines, - )?; + let (mut set, wires, center) = crate::scene::model::solid_model::display_from_solid( + solid, + color, + facet_resolution, + chordal_deflection, + isolines, + planar_isolines, + )?; let name = handle.value().to_string(); for mesh in &mut set.lods { mesh.name = name.clone(); @@ -3211,12 +3234,21 @@ impl Scene { mesh.color = color; } crate::scene::model::material_model::resolve_material_with_base( - &self.document, entity, color, None, self.material_base_dir.as_deref(), - ).apply_to_with_face_overrides( - &mut set, &self.document, self.material_base_dir.as_deref(), + &self.document, + entity, + color, + None, + self.material_base_dir.as_deref(), + ) + .apply_to_with_face_overrides( + &mut set, + &self.document, + self.material_base_dir.as_deref(), ); crate::scene::model::visual_style_model::apply_mesh_visual_style( - &mut set, &self.document, entity, + &mut set, + &self.document, + entity, ); } if let Some(entity) = self.document.get_entity_mut(handle) { @@ -3244,18 +3276,16 @@ impl Scene { /// Cache and display a Model-tab kernel solid. A failed preparation leaves /// the previous entity, body and mesh untouched. - pub fn register_solid_model( - &mut self, - handle: Handle, - solid: cadkernel::brep::Body, - ) -> bool { + pub fn register_solid_model(&mut self, handle: Handle, solid: cadkernel::brep::Body) -> bool { let Some(display) = self.prepare_solid_model_display(handle, &solid) else { return false; }; - if !display.0.complete && matches!( - self.document.solid_history_operation(handle), - Some(acadrust::objects::SolidHistoryOperation::Loft(_)) - ) { + if !display.0.complete + && matches!( + self.document.solid_history_operation(handle), + Some(acadrust::objects::SolidHistoryOperation::Loft(_)) + ) + { return false; } self.register_prepared_solid_model(handle, solid, display) @@ -3280,12 +3310,12 @@ impl Scene { let color = self.render_style(e).0; let mut material = crate::scene::model::material_model::resolve_material_with_base( - &self.document, - e, - color, - None, - self.material_base_dir.as_deref(), - ); + &self.document, + e, + color, + None, + self.material_base_dir.as_deref(), + ); if let Some(keep) = &self.refedit_keep { if !keep.contains(&h) { material.diffuse = crate::scene::cache::block_cache::fade_toward_bg( @@ -3326,21 +3356,19 @@ impl Scene { let color = self.render_style(entity).0; let mut material = crate::scene::model::material_model::resolve_material_with_base( - &self.document, - entity, - color, - None, - self.material_base_dir.as_deref(), - ); + &self.document, + entity, + color, + None, + self.material_base_dir.as_deref(), + ); if self .refedit_keep .as_ref() .is_some_and(|keep| !keep.contains(&handle)) { - material.diffuse = crate::scene::cache::block_cache::fade_toward_bg( - material.diffuse, - bg, - ); + material.diffuse = + crate::scene::cache::block_cache::fade_toward_bg(material.diffuse, bg); } (handle, material) }) @@ -3438,35 +3466,27 @@ impl Scene { } if let Some((flags, insertion_base, min_extents, max_extents, min_limits, max_limits)) = self.document.objects.values().find_map(|object| { - let ObjectType::Layout(layout) = object else { - return None; - }; - (layout.name == self.current_layout).then_some(( - layout.flags, - layout.insertion_base, - layout.min_extents, - layout.max_extents, - layout.min_limits, - layout.max_limits, - )) - }) { + let ObjectType::Layout(layout) = object else { + return None; + }; + (layout.name == self.current_layout).then_some(( + layout.flags, + layout.insertion_base, + layout.min_extents, + layout.max_extents, + layout.min_limits, + layout.max_limits, + )) + }) + { self.document.header.paper_space_linetype_scaling = flags & 1 != 0; self.document.header.paper_space_limit_check = flags & 2 != 0; - self.document.header.paper_space_insertion_base = acadrust::types::Vector3::new( - insertion_base.0, - insertion_base.1, - insertion_base.2, - ); - self.document.header.paper_space_extents_min = acadrust::types::Vector3::new( - min_extents.0, - min_extents.1, - min_extents.2, - ); - self.document.header.paper_space_extents_max = acadrust::types::Vector3::new( - max_extents.0, - max_extents.1, - max_extents.2, - ); + self.document.header.paper_space_insertion_base = + acadrust::types::Vector3::new(insertion_base.0, insertion_base.1, insertion_base.2); + self.document.header.paper_space_extents_min = + acadrust::types::Vector3::new(min_extents.0, min_extents.1, min_extents.2); + self.document.header.paper_space_extents_max = + acadrust::types::Vector3::new(max_extents.0, max_extents.1, max_extents.2); self.document.header.paper_space_limits_min = acadrust::types::Vector2::new(min_limits.0, min_limits.1); self.document.header.paper_space_limits_max = @@ -4006,8 +4026,7 @@ impl Scene { ps.paper_image_origin_x = l.paper_image_origin_x; ps.paper_image_origin_y = l.paper_image_origin_y; ps.shade_plot_mode = ShadePlotMode::from_code(l.shade_plot_mode); - ps.shade_plot_resolution = - ShadePlotResolutionLevel::from_code(l.shade_plot_resolution); + ps.shade_plot_resolution = ShadePlotResolutionLevel::from_code(l.shade_plot_resolution); ps.shade_plot_dpi = l.shade_plot_dpi; ps.plot_view_handle = l.plot_view_handle; ps.visual_style_handle = l.visual_style_handle; @@ -4216,9 +4235,7 @@ impl Scene { (x0 + left * factor, y0 + bottom * factor), (x1 - right * factor, y1 - top * factor), ); - if printable.1.0 - printable.0.0 < 1e-6 - || printable.1.1 - printable.0.1 < 1e-6 - { + if printable.1 .0 - printable.0 .0 < 1e-6 || printable.1 .1 - printable.0 .1 < 1e-6 { Some(((x0, y0), (x1, y1))) } else { Some(printable) @@ -4458,14 +4475,10 @@ impl Scene { if !has_matching_family { let unit_factor = self.annotation_scale_unit_factor(); - visible.extend( - self.default_scales() - .iter() - .map(|&(label, base_vp)| { - let vp = base_vp * unit_factor; - (label.to_string(), (1.0 / vp) as f32, vp) - }), - ); + visible.extend(self.default_scales().iter().map(|&(label, base_vp)| { + let vp = base_vp * unit_factor; + (label.to_string(), (1.0 / vp) as f32, vp) + })); } // If the current scale belongs to the opposite family and has no @@ -4542,14 +4555,17 @@ impl Scene { pub(crate) fn paper_annotation_scale_handle(&self) -> Option { self.scale_object_handle("1:1").or_else(|| { - self.document.objects.iter().find_map(|(handle, object)| match object { - ObjectType::Scale(scale) - if !scale.is_temporary && (scale.factor() - 1.0).abs() <= 1.0e-9 => - { - Some(*handle) - } - _ => None, - }) + self.document + .objects + .iter() + .find_map(|(handle, object)| match object { + ObjectType::Scale(scale) + if !scale.is_temporary && (scale.factor() - 1.0).abs() <= 1.0e-9 => + { + Some(*handle) + } + _ => None, + }) }) } @@ -4578,11 +4594,8 @@ impl Scene { let Some(EntityType::Viewport(viewport)) = self.document.get_entity(handle) else { return 1.0; }; - let paper_to_model = vp_effective_scale( - viewport.custom_scale, - viewport.view_height, - viewport.height, - ); + let paper_to_model = + vp_effective_scale(viewport.custom_scale, viewport.view_height, viewport.height); if paper_to_model.is_finite() && paper_to_model > 1.0e-9 { 1.0 / paper_to_model } else { @@ -4615,14 +4628,17 @@ impl Scene { } fn current_layout_object_handle(&self) -> Option { - self.document.objects.iter().find_map(|(handle, object)| match object { - ObjectType::Layout(layout) - if layout.name.eq_ignore_ascii_case(&self.current_layout) => - { - Some(*handle) - } - _ => None, - }) + self.document + .objects + .iter() + .find_map(|(handle, object)| match object { + ObjectType::Layout(layout) + if layout.name.eq_ignore_ascii_case(&self.current_layout) => + { + Some(*handle) + } + _ => None, + }) } pub fn annotation_all_visible(&self) -> bool { @@ -4718,12 +4734,10 @@ impl Scene { .collect(); let mut added = 0; for handle in handles { - let existed = crate::scene::annotative::object_scale_memberships( - &self.document, - handle, - ) - .iter() - .any(|(_, member)| *member == scale); + let existed = + crate::scene::annotative::object_scale_memberships(&self.document, handle) + .iter() + .any(|(_, member)| *member == scale); if !existed && crate::scene::annotative::create_annotation_context( &mut self.document, @@ -4859,16 +4873,23 @@ impl Scene { sh, ); } - let replacement = self.document.objects.iter().find_map(|(handle, object)| match object { - ObjectType::Scale(scale) if *handle != sh && !scale.is_temporary => Some(*handle), - _ => None, - }); + let replacement = self + .document + .objects + .iter() + .find_map(|(handle, object)| match object { + ObjectType::Scale(scale) if *handle != sh && !scale.is_temporary => Some(*handle), + _ => None, + }); let viewports: Vec<_> = self .document .entities() .filter_map(|entity| match entity { EntityType::Viewport(viewport) - if self.document.viewport_annotation_scale(viewport.common.handle) == Some(sh) => + if self + .document + .viewport_annotation_scale(viewport.common.handle) + == Some(sh) => { Some(viewport.common.handle) } @@ -5171,11 +5192,17 @@ impl Scene { } self.active_viewport .filter(|handle| { - matches!(self.document.get_entity(*handle), Some(EntityType::Viewport(_))) + matches!( + self.document.get_entity(*handle), + Some(EntityType::Viewport(_)) + ) }) .or_else(|| { self.selected.iter().copied().find(|handle| { - matches!(self.document.get_entity(*handle), Some(EntityType::Viewport(_))) + matches!( + self.document.get_entity(*handle), + Some(EntityType::Viewport(_)) + ) }) }) .or_else(|| self.first_viewport_handle()) @@ -5187,18 +5214,27 @@ impl Scene { } self.active_viewport .filter(|handle| { - matches!(self.document.get_entity(*handle), Some(EntityType::Viewport(_))) + matches!( + self.document.get_entity(*handle), + Some(EntityType::Viewport(_)) + ) }) .or_else(|| { self.selected.iter().copied().find(|handle| { - matches!(self.document.get_entity(*handle), Some(EntityType::Viewport(_))) + matches!( + self.document.get_entity(*handle), + Some(EntityType::Viewport(_)) + ) }) }) } fn viewport_scale_handle(&self, viewport: Handle) -> Option { if let Some(handle) = self.document.viewport_annotation_scale(viewport) { - if matches!(self.document.objects.get(&handle), Some(ObjectType::Scale(_))) { + if matches!( + self.document.objects.get(&handle), + Some(ObjectType::Scale(_)) + ) { return Some(handle); } } @@ -5243,11 +5279,12 @@ impl Scene { _ => None, }) .or_else(|| { - self.paper_annotation_scale_handle() - .and_then(|handle| match self.document.objects.get(&handle) { + self.paper_annotation_scale_handle().and_then(|handle| { + match self.document.objects.get(&handle) { Some(ObjectType::Scale(scale)) => Some(scale.name.clone()), _ => None, - }) + } + }) }) .unwrap_or_else(|| "1:1".to_string()) } @@ -5257,11 +5294,7 @@ impl Scene { self.set_viewport_scale_named_for(viewport, name) } - pub fn set_viewport_scale_named_for( - &mut self, - viewport: Handle, - name: &str, - ) -> Option { + pub fn set_viewport_scale_named_for(&mut self, viewport: Handle, name: &str) -> Option { let scale_handle = self.scale_handle_ensuring(name)?; let unit_factor = self.annotation_scale_unit_factor(); @@ -5306,18 +5339,11 @@ impl Scene { return None; }; - let effective = vp_effective_scale( - vp.custom_scale, - vp.view_height, - vp.height, - ); + let effective = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height); let expected = scale.factor() * self.annotation_scale_unit_factor(); - Some( - (effective - expected).abs() - <= 1.0e-6 * expected.max(1.0), - ) + Some((effective - expected).abs() <= 1.0e-6 * expected.max(1.0)) } pub fn sync_viewport_annotation_scale(&mut self) -> bool { let Some(viewport) = self.explicit_viewport_handle() else { @@ -5594,7 +5620,9 @@ impl Scene { mix(anno_scale_override .map(|scale| scale.to_bits() as u64) .unwrap_or(u64::MAX)); - mix(annotation_scale_handle.map(|handle| handle.value()).unwrap_or(0)); + mix(annotation_scale_handle + .map(|handle| handle.value()) + .unwrap_or(0)); mix(all_visible as u64); mix(self.viewport_style_key(style_viewport)); // quantized_wpp removed: GPU analytical rendering for circles/arcs/ @@ -5631,10 +5659,7 @@ impl Scene { /// incremental resident patch, else `None`. Read (not consumed) so every /// Model tile applies the same patch; gen-matching keeps it from applying to /// a viewport that rebuilt independently. - pub(crate) fn model_wire_patch_for( - &self, - gen: u64, - ) -> Option<(u64, Arc)> { + pub(crate) fn model_wire_patch_for(&self, gen: u64) -> Option<(u64, Arc)> { match &*self.model_wire_gpu_patch.borrow() { Some((prev, new, patch)) if *new == gen => Some((*prev, Arc::clone(patch))), _ => None, @@ -5830,15 +5855,12 @@ impl Scene { // face change made deleting an ordinary line rescan/re-upload the whole // fill pass on large drawings. let old_face_pass_changed = deltas.iter().any(|(handle, _)| { - layout - .ranges - .get(handle) - .is_some_and(|&(start, len)| { - start + len <= layout.marker_start - && owned[start..start + len] - .iter() - .any(|wire| !wire.fill_tris.is_empty()) - }) + layout.ranges.get(handle).is_some_and(|&(start, len)| { + start + len <= layout.marker_start + && owned[start..start + len] + .iter() + .any(|wire| !wire.fill_tris.is_empty()) + }) }); // Keep the submission-order directory current. Adds are inserted by @@ -5847,9 +5869,7 @@ impl Scene { // commit restores them at their original position. { let cache = self.sort_cache.borrow(); - let sort_map = cache - .as_ref() - .and_then(|(_, index)| index.get(&block)); + let sort_map = cache.as_ref().and_then(|(_, index)| index.get(&block)); let effective = |handle: Handle| { sort_map .and_then(|map| map.get(&handle.value())) @@ -5894,12 +5914,7 @@ impl Scene { return None; } for slot in &mut owned[start..start + old_len] { - *slot = WireModel::solid( - String::new(), - Vec::new(), - [0.0; 4], - false, - ); + *slot = WireModel::solid(String::new(), Vec::new(), [0.0; 4], false); slot.aabb = [ f32::INFINITY, f32::INFINITY, @@ -5908,8 +5923,7 @@ impl Scene { ]; } layout.vacant.insert(handle, (start, old_len)); - layout.tombstoned_wires = - layout.tombstoned_wires.saturating_add(old_len); + layout.tombstoned_wires = layout.tombstoned_wires.saturating_add(old_len); index_edits.push(WireIndexEdit { handle, start, @@ -5925,15 +5939,11 @@ impl Scene { if vacant_len != new_len || start + vacant_len > layout.marker_start { return None; } - for (slot, wire) in owned[start..start + vacant_len] - .iter_mut() - .zip(new_run) - { + for (slot, wire) in owned[start..start + vacant_len].iter_mut().zip(new_run) { *slot = wire; } layout.ranges.insert(handle, (start, new_len)); - layout.tombstoned_wires = - layout.tombstoned_wires.saturating_sub(vacant_len); + layout.tombstoned_wires = layout.tombstoned_wires.saturating_sub(vacant_len); index_edits.push(WireIndexEdit { handle, start, @@ -5949,10 +5959,7 @@ impl Scene { return None; } if old_len == new_len { - for (slot, wire) in owned[start..start + old_len] - .iter_mut() - .zip(new_run) - { + for (slot, wire) in owned[start..start + old_len].iter_mut().zip(new_run) { *slot = wire; } layout.ranges.insert(handle, (start, new_len)); @@ -6009,9 +6016,7 @@ impl Scene { } let added: HashSet = deltas .iter() - .filter_map(|&(handle, kind)| { - matches!(kind, ChangeKind::Added).then_some(handle) - }) + .filter_map(|&(handle, kind)| matches!(kind, ChangeKind::Added).then_some(handle)) .collect(); let mut saw_added = false; let mut new_handles_are_suffix = true; @@ -6025,10 +6030,12 @@ impl Scene { } let face_pass_changed = old_face_pass_changed || deltas.iter().any(|&(handle, _)| { - matches!(self.document.get_entity(handle), Some(EntityType::Face3D(_))) - || gpu_runs.get(&handle).is_some_and(|run| { - run.iter().any(|wire| !wire.fill_tris.is_empty()) - }) + matches!( + self.document.get_entity(handle), + Some(EntityType::Face3D(_)) + ) || gpu_runs + .get(&handle) + .is_some_and(|run| run.iter().any(|wire| !wire.fill_tris.is_empty())) }); let arc = Arc::new(owned); @@ -6301,20 +6308,15 @@ impl Scene { incremental_ok = false; break; } - let restored = cache - .blocks - .get(&block) - .and_then(|order| { - order.iter().find(|entry| entry.handle == value) - }); + let restored = cache.blocks.get(&block).and_then(|order| { + order.iter().find(|entry| entry.handle == value) + }); let Some(entry) = restored else { incremental_ok = false; break; }; - depths.insert( - value, - draw_depth_value(entry.label, entry.half_label), - ); + depths + .insert(value, draw_depth_value(entry.label, entry.half_label)); continue; } // A newly allocated handle cannot already have a @@ -6461,7 +6463,11 @@ impl Scene { // rebuild every hatch model: an O(N-hatch) stutter on hatch-heavy // drawings. Key on a signature of `selected` instead, so hover (which // never changes `selected`) keeps the cache warm. - let sel_sig = if tint_selected { self.selected_hatch_sig() } else { 0 }; + let sel_sig = if tint_selected { + self.selected_hatch_sig() + } else { + 0 + }; let target_block = self.content_render_block_handle(); let key = (target_block, self.current_layout.clone()); { @@ -6510,10 +6516,9 @@ impl Scene { None, tint_selected, )); - self.hatch_cache.borrow_mut().insert( - key, - (self.geometry_epoch, sel_sig, Arc::clone(&arc)), - ); + self.hatch_cache + .borrow_mut() + .insert(key, (self.geometry_epoch, sel_sig, Arc::clone(&arc))); arc } @@ -6581,12 +6586,8 @@ impl Scene { } else { self.paper_annotation_scale_handle() }; - let arc = Arc::new(self.wipeout_models( - target_block, - None, - scale, - self.annotation_all_visible(), - )); + let arc = + Arc::new(self.wipeout_models(target_block, None, scale, self.annotation_all_visible())); self.wipeout_cache .borrow_mut() .insert(key, (self.geometry_epoch, Arc::clone(&arc))); @@ -6673,8 +6674,7 @@ impl Scene { graph.walk_root( root, |entity, context| { - context.is_instanced() - || !self.entity_temporarily_hidden(entity.common().handle) + context.is_instanced() || !self.entity_temporarily_hidden(entity.common().handle) }, |entity, context| { let handle = entity.common().handle; @@ -6683,9 +6683,7 @@ impl Scene { }; let mut placed = model.clone(); if context.is_instanced() { - for (corner, low) in - placed.corners.iter_mut().zip(&mut placed.corners_low) - { + for (corner, low) in placed.corners.iter_mut().zip(&mut placed.corners_low) { let point = acadrust::types::Vector3::new( corner[0] as f64 + low[0] as f64, corner[1] as f64 + low[1] as f64, @@ -6706,11 +6704,7 @@ impl Scene { vertex.pos[2] as f64 + vertex.pos_low[2] as f64, ); let point = context.transform.apply(point); - vertex.pos = [ - point.x as f32, - point.y as f32, - point.z as f32, - ]; + vertex.pos = [point.x as f32, point.y as f32, point.z as f32]; vertex.pos_low = [ (point.x - vertex.pos[0] as f64) as f32, (point.y - vertex.pos[1] as f64) as f32, @@ -6719,19 +6713,24 @@ impl Scene { } let matrix = &context.transform.matrix.m; let linear = [ - matrix[0][0].to_bits(), matrix[0][1].to_bits(), matrix[0][2].to_bits(), - matrix[1][0].to_bits(), matrix[1][1].to_bits(), matrix[1][2].to_bits(), - matrix[2][0].to_bits(), matrix[2][1].to_bits(), matrix[2][2].to_bits(), + matrix[0][0].to_bits(), + matrix[0][1].to_bits(), + matrix[0][2].to_bits(), + matrix[1][0].to_bits(), + matrix[1][1].to_bits(), + matrix[1][2].to_bits(), + matrix[2][0].to_bits(), + matrix[2][1].to_bits(), + matrix[2][2].to_bits(), ]; let source_id = *image_sources .entry((handle.value(), linear)) .or_insert_with(crate::scene::model::instance_model::next_source_id); - placed.render_instance = Some( - crate::scene::model::instance_model::RenderInstance { + placed.render_instance = + Some(crate::scene::model::instance_model::RenderInstance { source_id, translation: [matrix[0][3], matrix[1][3], matrix[2][3]], - }, - ); + }); } placed.draw_depth = context.draw_depth(handle, depth_map.as_ref()); models.push(placed); @@ -6944,10 +6943,7 @@ impl Scene { let Some(handle) = set.entity_handle() else { continue; }; - lookup - .entry(handle) - .or_default() - .push(index as u32); + lookup.entry(handle).or_default().push(index as u32); } let lookup = Arc::new(lookup); *self.mesh_pick_lookup_cache.borrow_mut() = @@ -7042,17 +7038,15 @@ impl Scene { all } - fn active_live_section( - &self, - target_block: Handle, - ) -> Option { + fn active_live_section(&self, target_block: Handle) -> Option { self.document .entities() .filter_map(|entity| { let EntityType::Extended(extended) = entity else { return None; }; - let acadrust::entities::ExtendedEntityData::SectionObject(data) = &extended.data else { + let acadrust::entities::ExtendedEntityData::SectionObject(data) = &extended.data + else { return None; }; let owner = extended.common.owner_handle; @@ -7061,11 +7055,12 @@ impl Scene { && (owner.is_null() || owner == target_block)) .then(|| LiveSection { data: data.clone(), - slice_depth: crate::entities::extended::section_is_slice(extended) - .then(|| { + slice_depth: crate::entities::extended::section_is_slice(extended).then( + || { crate::entities::extended::section_slice_depth(extended) .unwrap_or(0.0) - }), + }, + ), }) }) .last() @@ -7091,9 +7086,7 @@ impl Scene { let Some(body) = self.sectioned_body(handle, section)? else { return Ok(None); }; - let (_, wires, center) = self - .prepare_solid_model_display(handle, &body) - .ok_or(())?; + let (_, wires, center) = self.prepare_solid_model_display(handle, &body).ok_or(())?; let mut entity = self.document.get_entity(handle).cloned().ok_or(())?; let reference = acadrust::types::Vector3::new(center[0], center[1], center[2]); match &mut entity { @@ -7179,13 +7172,8 @@ impl Scene { if depth <= 1e-12 { return Ok(Some(current)); } - let back = section_plane( - origin + viewing * depth, - tangent, - -viewing, - body_to_world, - ) - .ok_or(())?; + let back = + section_plane(origin + viewing * depth, tangent, -viewing, body_to_world).ok_or(())?; let Some(next) = keep_section_positive(¤t, back)? else { return Ok(None); }; @@ -7208,8 +7196,16 @@ impl Scene { return Ok(Some(current)); } - let first = data.vertices.first().map(|point| glam::DVec3::new(point.x, point.y, point.z)).ok_or(())?; - let last = data.vertices.last().map(|point| glam::DVec3::new(point.x, point.y, point.z)).ok_or(())?; + let first = data + .vertices + .first() + .map(|point| glam::DVec3::new(point.x, point.y, point.z)) + .ok_or(())?; + let last = data + .vertices + .last() + .map(|point| glam::DVec3::new(point.x, point.y, point.z)) + .ok_or(())?; for plane in [ section_plane(first, vertical, tangent, body_to_world), section_plane(last, vertical, -tangent, body_to_world), @@ -7348,7 +7344,11 @@ impl Scene { ^ self.viewport_style_key(Some(viewport)).rotate_left(23); let sig = Self::frozen_layers_sig(frozen) ^ context_sig; let key = (target_block, self.current_layout.clone(), sig); - let sel = if tint_selected { self.selected_hatch_sig() } else { 0 }; + let sel = if tint_selected { + self.selected_hatch_sig() + } else { + 0 + }; if let Some((e, s, arc)) = self.frozen_hatch_cache.borrow().get(&key) { if *e == self.geometry_epoch && *s == sel { return Arc::clone(arc); @@ -7380,8 +7380,8 @@ impl Scene { let target_block = self.content_render_block_handle(); let scale = self.viewport_scale_handle(viewport); let all_visible = self.annotation_all_visible(); - let context_sig = scale.map_or(0, |handle| handle.value()) - ^ u64::from(all_visible).rotate_left(61); + let context_sig = + scale.map_or(0, |handle| handle.value()) ^ u64::from(all_visible).rotate_left(61); let sig = Self::frozen_layers_sig(frozen) ^ context_sig; let key = (target_block, self.current_layout.clone(), sig); if let Some((e, arc)) = self.frozen_wipeout_cache.borrow().get(&key) { @@ -7389,12 +7389,7 @@ impl Scene { return Arc::clone(arc); } } - let arc = Arc::new(self.wipeout_models( - target_block, - Some(frozen), - scale, - all_visible, - )); + let arc = Arc::new(self.wipeout_models(target_block, Some(frozen), scale, all_visible)); self.frozen_wipeout_cache .borrow_mut() .insert(key, (self.geometry_epoch, Arc::clone(&arc))); @@ -7550,8 +7545,7 @@ impl Scene { graph.walk_root( self.render_scene_root(layout_block), |entity, context| { - context.is_instanced() - || !self.entity_temporarily_hidden(entity.common().handle) + context.is_instanced() || !self.entity_temporarily_hidden(entity.common().handle) }, |entity, context| { if !context.is_instanced() { @@ -7563,8 +7557,7 @@ impl Scene { }; let sectioned; let set = if let Some((section, body)) = live_section.and_then(|section| { - self.section_source_body(handle) - .map(|body| (section, body)) + self.section_source_body(handle).map(|body| (section, body)) }) { match Self::section_body( &body, @@ -7591,14 +7584,10 @@ impl Scene { }; let inherit = self.mesh_inherit_for_path(&context.insert_path); let own_alpha = set.display_color().map_or(1.0, |color| color[3]); - let mut transformed = - transform_block_mesh_lod_set(set, &context.transform); - if let Some(color) = self.block_mesh_override_color( - entity, - handle, - inherit.as_ref(), - own_alpha, - ) { + let mut transformed = transform_block_mesh_lod_set(set, &context.transform); + if let Some(color) = + self.block_mesh_override_color(entity, handle, inherit.as_ref(), own_alpha) + { transformed.instance_color = Some(color); if let Some(material) = transformed.material.as_mut() { if material.handle.is_none() { @@ -7606,8 +7595,7 @@ impl Scene { } } } - if let Some(material) = - self.block_mesh_override_material(entity, inherit.as_ref()) + if let Some(material) = self.block_mesh_override_material(entity, inherit.as_ref()) { material.apply_to_with_face_overrides( &mut transformed, @@ -7622,10 +7610,7 @@ impl Scene { out } - fn mesh_inherit_for_path( - &self, - path: &[DxfInsert], - ) -> Option { + fn mesh_inherit_for_path(&self, path: &[DxfInsert]) -> Option { let first = path.first()?; let entity = EntityType::Insert(first.clone()); let bg = self.current_bg(); @@ -7634,31 +7619,26 @@ impl Scene { bg, ); let layer0_color = crate::scene::view::render::adapt_to_bg( - crate::scene::view::render::layer_render_style( - &self.document, - &first.common.layer, - ) - .color, + crate::scene::view::render::layer_render_style(&self.document, &first.common.layer) + .color, bg, ); let mut inherit = BlockMeshInherit { insert_color, layer0_color, - insert_material: - crate::scene::model::material_model::resolve_material_with_base( - &self.document, - &entity, - insert_color, - None, - self.material_base_dir.as_deref(), - ), - layer0_material: - crate::scene::model::material_model::resolve_layer_material_with_base( - &self.document, - &first.common.layer, - layer0_color, - self.material_base_dir.as_deref(), - ), + insert_material: crate::scene::model::material_model::resolve_material_with_base( + &self.document, + &entity, + insert_color, + None, + self.material_base_dir.as_deref(), + ), + layer0_material: crate::scene::model::material_model::resolve_layer_material_with_base( + &self.document, + &first.common.layer, + layer0_color, + self.material_base_dir.as_deref(), + ), }; for insert in &path[1..] { inherit = self.chain_mesh_inherit(insert, &inherit); @@ -7689,10 +7669,7 @@ impl Scene { let on_l0 = crate::scene::view::render::is_effective_layer_zero(&ins.common.layer); let insert_entity = EntityType::Insert(ins.clone()); let has_book_color = - crate::scene::view::render::has_resolved_book_color( - &self.document, - &insert_entity, - ); + crate::scene::view::render::has_resolved_book_color(&self.document, &insert_entity); let child_ins_color = if !has_book_color && ins.common.color == Color::ByBlock { parent.insert_color } else if !has_book_color && on_l0 && ins.common.color == Color::ByLayer { @@ -7762,8 +7739,7 @@ impl Scene { use acadrust::types::Color; let common = e.common(); let on_l0 = crate::scene::view::render::is_effective_layer_zero(&common.layer); - let has_book_color = - crate::scene::view::render::has_resolved_book_color(&self.document, e); + let has_book_color = crate::scene::view::render::has_resolved_book_color(&self.document, e); let mut c = if !has_book_color && common.color == Color::ByBlock { inherit.insert_color } else if !has_book_color && on_l0 && common.color == Color::ByLayer { @@ -7870,21 +7846,17 @@ impl Scene { let c = self.insert_hatch_cache.borrow(); if let Some((epoch, cached_space, ref arc)) = *c { if cached_space == space_key - && self.category_cache_valid( - epoch, - CACHE_CATEGORY_INSERT_HATCH, - |handle| { - self.document.get_entity(handle).is_some_and(|entity| { - render_graph::entity_render_block_uses( - &self.document, - entity, - self.annotation_scale, - ) - .into_iter() - .any(|block_use| block_use.active) - }) - }, - ) + && self.category_cache_valid(epoch, CACHE_CATEGORY_INSERT_HATCH, |handle| { + self.document.get_entity(handle).is_some_and(|entity| { + render_graph::entity_render_block_uses( + &self.document, + entity, + self.annotation_scale, + ) + .into_iter() + .any(|block_use| block_use.active) + }) + }) { let arc = Arc::clone(arc); drop(c); @@ -7941,20 +7913,14 @@ impl Scene { { continue; } - if !self.belongs_to_visible_block( - common.handle, - common.owner_handle, - interaction_block, - ) { + if !self.belongs_to_visible_block(common.handle, common.owner_handle, interaction_block) + { continue; } - for block_use in render_graph::entity_render_block_uses( - &self.document, - host, - self.annotation_scale, - ) - .into_iter() - .filter(|block_use| block_use.active) + for block_use in + render_graph::entity_render_block_uses(&self.document, host, self.annotation_scale) + .into_iter() + .filter(|block_use| block_use.active) { if !render_graph::block_contains_hatch( &self.document, @@ -7980,8 +7946,7 @@ impl Scene { context.style_for(&self.document, entity).0, self.current_bg(), ); - let Some(mut model) = Self::hatch_model_from_dxf(&hatch, color) - else { + let Some(mut model) = Self::hatch_model_from_dxf(&hatch, color) else { return; }; for clip in &context.clips { @@ -8066,9 +8031,8 @@ impl Scene { ) -> Option> { let source = Arc::as_ptr(wires) as usize; let mut cache = self.interaction_index_cache.borrow_mut(); - cache.retain(|(epoch, _, weak, _)| { - *epoch == self.geometry_epoch && weak.strong_count() > 0 - }); + cache + .retain(|(epoch, _, weak, _)| *epoch == self.geometry_epoch && weak.strong_count() > 0); if let Some(position) = cache.iter().position(|(_, ptr, weak, _)| { *ptr == source && weak @@ -8104,21 +8068,13 @@ impl Scene { && weak.strong_count() > 0 && !(*cached_epoch == epoch && *ptr == source) }); - cache.push(( - epoch, - source, - Arc::downgrade(&wires), - Arc::clone(&index), - )); + cache.push((epoch, source, Arc::downgrade(&wires), Arc::clone(&index))); if cache.len() > MAX_CACHED_SOURCES { cache.remove(0); } if epoch == self.geometry_epoch && self.current_layout == "Model" { - *self.interaction_base_index_cache.borrow_mut() = Some(( - epoch, - self.interaction_space_key(), - index, - )); + *self.interaction_base_index_cache.borrow_mut() = + Some((epoch, self.interaction_space_key(), index)); } } @@ -8191,8 +8147,7 @@ impl Scene { (*epoch, Arc::clone(index)) }; let changes = self.replay_since(epoch)?; - (changes.len() <= Self::INTERACTION_OVERLAY_MAX_HANDLES) - .then_some((epoch, index, changes)) + (changes.len() <= Self::INTERACTION_OVERLAY_MAX_HANDLES).then_some((epoch, index, changes)) } fn interaction_overlay_changed_index( @@ -8225,9 +8180,8 @@ impl Scene { }) .collect(); let wires = Arc::new(self.wire_models_for(&changed_live)); - let index = Arc::new( - crate::scene::pick::interaction_index::InteractionIndex::build(&wires), - ); + let index = + Arc::new(crate::scene::pick::interaction_index::InteractionIndex::build(&wires)); *self.interaction_overlay_index_cache.borrow_mut() = Some(( self.geometry_epoch, base_epoch, @@ -8312,11 +8266,8 @@ impl Scene { self.interaction_overlay_changed_index(base_epoch, &changes); let changed_ms = t_changed.elapsed().as_secs_f64() * 1000.0; let t_local = iced::time::Instant::now(); - let (local, base_slots, changed_slots) = self.interaction_overlay_wires( - keys.iter().copied(), - &changes, - &changed_wires, - ); + let (local, base_slots, changed_slots) = + self.interaction_overlay_wires(keys.iter().copied(), &changes, &changed_wires); let local_ms = t_local.elapsed().as_secs_f64() * 1000.0; let t_remap = iced::time::Instant::now(); let mut result = if area_only { @@ -8325,13 +8276,13 @@ impl Scene { base.query_remapped_xy(Arc::clone(&local), &base_slots, aabb) }; if area_only { - result.extend_indexed_area( - changed_index.query_remapped_xy_area(local, &changed_slots, aabb), - ); + result.extend_indexed_area(changed_index.query_remapped_xy_area( + local, + &changed_slots, + aabb, + )); } else { - result.extend_indexed( - changed_index.query_remapped_xy(local, &changed_slots, aabb), - ); + result.extend_indexed(changed_index.query_remapped_xy(local, &changed_slots, aabb)); } let remap_ms = t_remap.elapsed().as_secs_f64() * 1000.0; let total_ms = t0.elapsed().as_secs_f64() * 1000.0; @@ -8357,7 +8308,7 @@ impl Scene { } } else if allow_pending_empty && self.interaction_index_pending_key.get() - == Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize)) + == Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize)) { crate::scene::pick::interaction_index::InteractionCandidates::pending(wires) } else { @@ -8384,11 +8335,8 @@ impl Scene { self.interaction_overlay_changed_index(base_epoch, &changes); let changed_ms = t_changed.elapsed().as_secs_f64() * 1000.0; let t_local = iced::time::Instant::now(); - let (local, base_slots, changed_slots) = self.interaction_overlay_wires( - keys.iter().copied(), - &changes, - &changed_wires, - ); + let (local, base_slots, changed_slots) = + self.interaction_overlay_wires(keys.iter().copied(), &changes, &changed_wires); let local_ms = t_local.elapsed().as_secs_f64() * 1000.0; let t_remap = iced::time::Instant::now(); let mut result = base.query_remapped_screen( @@ -8427,7 +8375,7 @@ impl Scene { index.query_screen(wires, screen_rect, view_rot, eye, bounds) } else if allow_pending_empty && self.interaction_index_pending_key.get() - == Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize)) + == Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize)) { crate::scene::pick::interaction_index::InteractionCandidates::pending(wires) } else { @@ -8443,25 +8391,16 @@ impl Scene { include_line_weight: bool, ) -> f32 { if let Some((base_epoch, base, changes)) = self.interaction_overlay_base() { - let (_, changed) = - self.interaction_overlay_changed_index(base_epoch, &changes); + let (_, changed) = self.interaction_overlay_changed_index(base_epoch, &changes); base.pick_radius_px( - changed.pick_radius_px( - base_radius_px, - viewport_height_px, - include_line_weight, - ), + changed.pick_radius_px(base_radius_px, viewport_height_px, include_line_weight), viewport_height_px, include_line_weight, ) } else { self.cached_interaction_index(wires) .map_or(base_radius_px, |index| { - index.pick_radius_px( - base_radius_px, - viewport_height_px, - include_line_weight, - ) + index.pick_radius_px(base_radius_px, viewport_height_px, include_line_weight) }) } } @@ -8503,13 +8442,13 @@ impl Scene { *epoch, CACHE_CATEGORY_INTERACTION, |handle| { - self.hatches.contains_key(&handle) - || self.meshes.contains_key(&handle) - || self.block_meshes.contains_key(&handle) - || matches!( - self.document.get_entity(handle), - Some(EntityType::Insert(_)) - ) + self.hatches.contains_key(&handle) + || self.meshes.contains_key(&handle) + || self.block_meshes.contains_key(&handle) + || matches!( + self.document.get_entity(handle), + Some(EntityType::Insert(_)) + ) }, ) => { @@ -8706,8 +8645,7 @@ impl Scene { { return crate::scene::pick::interaction_index::InteractionCandidates::all(wires); } - let pad_px = - self.indexed_interaction_pick_radius(&wires, 0.0, bounds.height, true); + let pad_px = self.indexed_interaction_pick_radius(&wires, 0.0, bounds.height, true); if flat_ortho { let world_x_px = ((view_rot.x_axis.x * bounds.width * 0.5).powi(2) + (view_rot.x_axis.y * bounds.height * 0.5).powi(2)) @@ -8923,8 +8861,14 @@ impl Scene { rect }, ); - let area_candidates = - self.interaction_candidates_in_aabb(all_wires, world_aabb, screen_rect, view_rot, eye, bounds); + let area_candidates = self.interaction_candidates_in_aabb( + all_wires, + world_aabb, + screen_rect, + view_rot, + eye, + bounds, + ); let candidate_handles = self.interaction_candidate_handles(&area_candidates); let wire_hits = if open_fence { crate::scene::pick::hit_test::poly_fence_hit( @@ -9058,14 +9002,7 @@ impl Scene { bounds: iced::Rectangle, candidate_handles: Option<&HashSet>, ) -> Option { - self.solid_hit( - cursor, - view_rot, - eye, - bounds, - candidate_handles, - false, - ) + self.solid_hit(cursor, view_rot, eye, bounds, candidate_handles, false) } /// Normal object selection for shaded 3D bodies is edge based. This keeps @@ -9089,12 +9026,7 @@ impl Scene { return None; } let (edges, edges_low) = set.geometry_edges(); - (!edges.is_empty()).then_some(( - handle, - edges, - edges_low, - set.instance_transform, - )) + (!edges.is_empty()).then_some((handle, edges, edges_low, set.instance_transform)) }), view_rot, eye, @@ -9160,10 +9092,7 @@ impl Scene { model::solid_model::planar_face_normal(body, face).map(glam::DVec3::from_array) } - pub fn view_center_surface_pivot( - &self, - bounds: iced::Rectangle, - ) -> Option { + pub fn view_center_surface_pivot(&self, bounds: iced::Rectangle) -> Option { let camera = self.camera.borrow(); let view_rot = camera.view_proj_rte(bounds); let eye = camera.eye(); @@ -9199,14 +9128,7 @@ impl Scene { bounds: iced::Rectangle, candidate_handles: Option<&HashSet>, ) -> Option { - self.solid_hit( - cursor, - view_rot, - eye, - bounds, - candidate_handles, - true, - ) + self.solid_hit(cursor, view_rot, eye, bounds, candidate_handles, true) } fn solid_hit( @@ -9606,7 +9528,10 @@ impl Scene { let members = self.block_members(); visible_index_ms = crate::perf::elapsed_ms(t_members); let (_, by_block) = &*members; - let claimed = by_block.get(&block_handle).map(Vec::as_slice).unwrap_or(&[]); + let claimed = by_block + .get(&block_handle) + .map(Vec::as_slice) + .unwrap_or(&[]); visible_candidates = claimed.len(); let mut out: Vec<&EntityType> = Vec::with_capacity(claimed.len()); for &handle in claimed { @@ -9727,7 +9652,9 @@ impl Scene { } } mix(anno.to_bits() as u64); - mix(annotation_scale_handle.map(|handle| handle.value()).unwrap_or(0)); + mix(annotation_scale_handle + .map(|handle| handle.value()) + .unwrap_or(0)); mix(all_visible as u64); // Direct entities and inherited/faded block colours still bake // the background before Batches::finalize records its inputs. @@ -9950,7 +9877,10 @@ vis_index={:.1} visible_probe={:.1}", fn unindexable_handles(&self) -> std::cell::Ref<'_, (u64, Vec)> { { let cache = self.unindexable_cache.borrow(); - if cache.as_ref().is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) { + if cache + .as_ref() + .is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) + { drop(cache); return std::cell::Ref::map(self.unindexable_cache.borrow(), |c| { c.as_ref().unwrap() @@ -9985,9 +9915,9 @@ vis_index={:.1} visible_probe={:.1}", } if let Some(since) = cached_epoch { - let lone_add = self.replay_since(since).filter(|deltas| { - deltas.len() == 1 && deltas[0].1 == ChangeKind::Added - }); + let lone_add = self + .replay_since(since) + .filter(|deltas| deltas.len() == 1 && deltas[0].1 == ChangeKind::Added); if let Some(deltas) = lone_add { let handle = deltas[0].0; // Resolve the owner before touching the cache: both lookups @@ -10026,8 +9956,7 @@ vis_index={:.1} visible_probe={:.1}", } } } - *self.block_members_cache.borrow_mut() = - Some((self.geometry_epoch, members)); + *self.block_members_cache.borrow_mut() = Some((self.geometry_epoch, members)); std::cell::Ref::map(self.block_members_cache.borrow(), |c| c.as_ref().unwrap()) } @@ -10159,9 +10088,7 @@ vis_index={:.1} visible_probe={:.1}", EntityType::Dimension(dimension) => { normalize_name(&dimension.base().style_name).hash(&mut hasher) } - EntityType::Leader(leader) => { - normalize_name(&leader.dimension_style).hash(&mut hasher) - } + EntityType::Leader(leader) => normalize_name(&leader.dimension_style).hash(&mut hasher), EntityType::Tolerance(tolerance) => { normalize_name(&tolerance.dimension_style_name).hash(&mut hasher) } @@ -10206,8 +10133,7 @@ vis_index={:.1} visible_probe={:.1}", let Some(entity) = self.document.get_entity(*handle) else { return false; }; - index.signatures.get(handle).copied() - == Some(self.entity_dependency_signature(entity)) + index.signatures.get(handle).copied() == Some(self.entity_dependency_signature(entity)) }); if !unchanged { cache.take(); @@ -10379,10 +10305,7 @@ vis_index={:.1} visible_probe={:.1}", } EntityType::Dimension(dimension) => { add(&mut index.dim_styles, &dimension.base().style_name); - if let Some(style) = self - .document - .dim_styles - .get(&dimension.base().style_name) + if let Some(style) = self.document.dim_styles.get(&dimension.base().style_name) { add(&mut index.text_styles, &style.dimtxsty); } @@ -10449,7 +10372,8 @@ vis_index={:.1} visible_probe={:.1}", EntityType::MultiLeader(leader) => { add_handle(&mut index.object_styles, leader.style_handle); for handle in [leader.text_style_handle, leader.context.text_style_handle] { - if let Some(name) = handle.and_then(|handle| text_style_names.get(&handle)) { + if let Some(name) = handle.and_then(|handle| text_style_names.get(&handle)) + { add(&mut index.text_styles, name); } } @@ -10472,11 +10396,7 @@ vis_index={:.1} visible_probe={:.1}", index } - fn dependency_targets( - &self, - kind: DependencyKind, - names: &[String], - ) -> DependencyTargets { + fn dependency_targets(&self, kind: DependencyKind, names: &[String]) -> DependencyTargets { if self.dependency_index_cache.borrow().is_none() { *self.dependency_index_cache.borrow_mut() = Some(self.rebuild_dependency_index()); } @@ -10730,7 +10650,13 @@ vis_index={:.1} visible_probe={:.1}", /// Resolve the active layout and annotation context for tessellation. fn tessellation_context( &self, - ) -> ([f32; 4], f32, Option, Arc, bool) { + ) -> ( + [f32; 4], + f32, + Option, + Arc, + bool, + ) { let is_model = self.current_layout == "Model"; let bg = if is_model { self.bg_color @@ -10763,8 +10689,7 @@ vis_index={:.1} visible_probe={:.1}", } fn tessellate_one(&self, e: &EntityType) -> Vec { - let (bg, anno, annotation_scale_handle, blk_cache, paper) = - self.tessellation_context(); + let (bg, anno, annotation_scale_handle, blk_cache, paper) = self.tessellation_context(); // tessellate_one is used for one-off lookups (hit test, properties). // Skip culling here so the caller always gets the full geometry. tessellate_entity( @@ -10866,11 +10791,7 @@ vis_index={:.1} visible_probe={:.1}", continue; } for (index, &[x, y, z]) in wire.points.iter().enumerate() { - let [lx, ly, lz] = wire - .points_low - .get(index) - .copied() - .unwrap_or([0.0; 3]); + let [lx, ly, lz] = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); include(glam::DVec3::new( x as f64 + lx as f64, y as f64 + ly as f64, @@ -10909,13 +10830,8 @@ vis_index={:.1} visible_probe={:.1}", &self.document, &self.document.header.current_annotation_scale, ); - let wires = self.resident_wires_for( - model_block, - Some(self.annotation_scale), - scale, - None, - None, - ); + let wires = + self.resident_wires_for(model_block, Some(self.annotation_scale), scale, None, None); let mut min = [f64::INFINITY; 3]; let mut max = [f64::NEG_INFINITY; 3]; let mut has_points = false; @@ -10940,15 +10856,25 @@ vis_index={:.1} visible_probe={:.1}", continue; }; if !self.mesh_entity_visible(handle) - || !self.belongs_to_visible_block(handle, entity.common().owner_handle, model_block) + || !self.belongs_to_visible_block( + handle, + entity.common().owner_handle, + model_block, + ) { continue; } let [ax, ay, bx, by] = set.world_aabb; let [az, bz] = set.z_aabb; - let (ax, ay, bx, by, az, bz) = (ax as f64, ay as f64, bx as f64, by as f64, az as f64, bz as f64); - if (ax as f32).is_finite() && (ay as f32).is_finite() && (az as f32).is_finite() - && (bx as f32).is_finite() && (by as f32).is_finite() && (bz as f32).is_finite() + let (ax, ay, bx, by, az, bz) = ( + ax as f64, ay as f64, bx as f64, by as f64, az as f64, bz as f64, + ); + if (ax as f32).is_finite() + && (ay as f32).is_finite() + && (az as f32).is_finite() + && (bx as f32).is_finite() + && (by as f32).is_finite() + && (bz as f32).is_finite() { min[0] = min[0].min(ax.min(bx)); min[1] = min[1].min(ay.min(by)); @@ -11079,9 +11005,8 @@ mod section_tests { assert_near(min, [0.0, 0.0, 0.0]); assert_near(max, [5.0, 10.0, 10.0]); - let (min, max) = bounds( - Scene::section_body(&body, §ion(1, 0.0), Some(2.0), None).unwrap(), - ); + let (min, max) = + bounds(Scene::section_body(&body, §ion(1, 0.0), Some(2.0), None).unwrap()); assert_near(min, [3.0, 0.0, 0.0]); assert_near(max, [5.0, 10.0, 10.0]); @@ -11097,20 +11022,17 @@ mod section_tests { #[test] fn instance_transform_pulls_the_section_plane_into_body_space() { let body = cadkernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap(); - let transform = acadrust::types::Transform::from_scaling( - acadrust::types::Vector3::new(2.0, 1.0, 1.0), - ) - .then(&acadrust::types::Transform::from_translation( - acadrust::types::Vector3::new(10.0, 0.0, 0.0), - )); + let transform = + acadrust::types::Transform::from_scaling(acadrust::types::Vector3::new(2.0, 1.0, 1.0)) + .then(&acadrust::types::Transform::from_translation( + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + )); let mut data = section(1, 0.0); for point in &mut data.vertices { point.x = 20.0; } - let (min, max) = bounds( - Scene::section_body(&body, &data, None, Some(&transform)).unwrap(), - ); + let (min, max) = bounds(Scene::section_body(&body, &data, None, Some(&transform)).unwrap()); assert_near(min, [0.0, 0.0, 0.0]); assert_near(max, [5.0, 10.0, 10.0]); } @@ -11320,10 +11242,7 @@ mod journal_tests { s.invalidate_annotation_dependencies(); let annotation = as_map(s.replay_since(before_annotation)).unwrap(); assert_eq!(annotation.len(), 1); - assert_eq!( - annotation.get(&annotative), - Some(&ChangeKind::Modified) - ); + assert_eq!(annotation.get(&annotative), Some(&ChangeKind::Modified)); } #[test] @@ -11378,9 +11297,9 @@ mod journal_tests { /// A line has tangent geometry but does not feed the analytical uploads. #[test] fn the_partition_membership_test_separates_lines_from_curves() { + use crate::scene::pipeline::wire_arena::feeds_analytical_uploads; use acadrust::entities::{Circle, Line}; use acadrust::types::Vector3; - use crate::scene::pipeline::wire_arena::feeds_analytical_uploads; let mut scene = Scene::new(); let feeds = |scene: &Scene, handle: Handle| { @@ -11469,14 +11388,32 @@ mod journal_tests { add(&mut scene, 1.0); // Build the index, then add one more so the next call folds. let block = scene.model_space_block_handle(); - let before = scene.block_members().1.get(&block).cloned().unwrap_or_default(); + let before = scene + .block_members() + .1 + .get(&block) + .cloned() + .unwrap_or_default(); let third = add(&mut scene, 2.0); - let folded = scene.block_members().1.get(&block).cloned().unwrap_or_default(); + let folded = scene + .block_members() + .1 + .get(&block) + .cloned() + .unwrap_or_default(); scene.block_members_cache.borrow_mut().take(); - let rebuilt = scene.block_members().1.get(&block).cloned().unwrap_or_default(); + let rebuilt = scene + .block_members() + .1 + .get(&block) + .cloned() + .unwrap_or_default(); - assert_eq!(folded, rebuilt, "the folded index must equal a full rebuild"); + assert_eq!( + folded, rebuilt, + "the folded index must equal a full rebuild" + ); assert_eq!( folded.last(), Some(&third), @@ -11553,10 +11490,8 @@ mod journal_tests { scene.document.layers.add_or_replace(hidden); let mut on_layer = |layer: &str| { - let mut line = Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(1.0, 0.0, 0.0), - ); + let mut line = + Line::from_points(Vector3::new(0.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0)); line.common.layer = layer.to_string(); scene.add_entity(EntityType::Line(line)) }; @@ -11829,10 +11764,7 @@ mod journal_tests { // Replay the same Added delta history Undo emits. The entity must return // to its original physical range instead of appending/shifting the set. - let restored = s.apply_entity_delta( - &[(h2, Some(Arc::clone(&h2_image)), None)], - true, - ); + let restored = s.apply_entity_delta(&[(h2, Some(Arc::clone(&h2_image)), None)], true); for &(handle, _) in &restored { s.reseed_derived_caches(handle); } @@ -11841,8 +11773,7 @@ mod journal_tests { assert_eq!(restored_names, before_erase_names); assert!(s.resident_wire_sets.borrow().values().any(|set| { set.layout.as_ref().is_some_and(|layout| { - layout.ranges.get(&h2).map(|range| range.0) - == Some(original_h2_start) + layout.ranges.get(&h2).map(|range| range.0) == Some(original_h2_start) && !layout.vacant.contains_key(&h2) }) })); @@ -11972,15 +11903,21 @@ mod journal_tests { let cached_epoch = s.geometry_epoch; s.erase_entities(&[line]); - assert!(s.category_cache_valid(cached_epoch, CACHE_CATEGORY_HATCH, |h| { - s.hatches.contains_key(&h) - })); - assert!(s.category_cache_valid(cached_epoch, CACHE_CATEGORY_IMAGE, |h| { - s.images.contains_key(&h) - })); - assert!(s.category_cache_valid(cached_epoch, CACHE_CATEGORY_MESH, |h| { - s.meshes.contains_key(&h) - })); + assert!( + s.category_cache_valid(cached_epoch, CACHE_CATEGORY_HATCH, |h| { + s.hatches.contains_key(&h) + }) + ); + assert!( + s.category_cache_valid(cached_epoch, CACHE_CATEGORY_IMAGE, |h| { + s.images.contains_key(&h) + }) + ); + assert!( + s.category_cache_valid(cached_epoch, CACHE_CATEGORY_MESH, |h| { + s.meshes.contains_key(&h) + }) + ); assert!(s.text_unchanged(cached_epoch)); } } @@ -12146,7 +12083,8 @@ mod delta_undo_tests { let handle = scene.add_entity(EntityType::RasterImage(image)); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned()); - let (entities, objects, _sketch_constraints, _named_parameters) = rec.into_recorded_images(); + let (entities, objects, _parametric_constraints, _named_parameters) = + rec.into_recorded_images(); assert_eq!(entities.len(), 1); assert_eq!(entities[0].0, handle); assert_eq!(objects.len(), 1); @@ -12170,7 +12108,8 @@ mod delta_undo_tests { scene.erase_entities(&[h1]); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned()); - let (entities, objects, _sketch_constraints, _named_parameters) = rec.into_recorded_images(); + let (entities, objects, _parametric_constraints, _named_parameters) = + rec.into_recorded_images(); assert_eq!(entities.len(), 1); assert_eq!(entities[0].0, h1); assert_eq!(objects.len(), 1); @@ -12222,7 +12161,12 @@ mod redraw_tests { ViewportInstance { handle: Handle::NULL, tile_idx: Some(idx), - screen_rect: iced::Rectangle { x: 0.0, y: 0.0, width: 1.0, height: 1.0 }, + screen_rect: iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1.0, + height: 1.0, + }, camera: Camera::default(), render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D, active, @@ -12241,13 +12185,28 @@ mod redraw_tests { scene.request_refresh(ViewportRefreshScope::Active); let active_id = scene.instance_id_for(&tile_inst(0, true)); assert!(scene.refresh_pending_any(), "a request must be pending"); - assert!(scene.refresh_consume(active_id), "active tile id must be consumed"); - assert!(!scene.refresh_consume(active_id), "second consume for the same id is empty (one-shot per id)"); - assert!(!scene.refresh_pending_any(), "id set empty after its only target consumed it"); + assert!( + scene.refresh_consume(active_id), + "active tile id must be consumed" + ); + assert!( + !scene.refresh_consume(active_id), + "second consume for the same id is empty (one-shot per id)" + ); + assert!( + !scene.refresh_pending_any(), + "id set empty after its only target consumed it" + ); // REDRAW never touches geometry / block epochs. - assert_eq!(scene.geometry_epoch, geom_before, "REDRAW must not bump geometry_epoch"); - assert_eq!(scene.block_epoch, block_before, "REDRAW must not bump block_epoch"); + assert_eq!( + scene.geometry_epoch, geom_before, + "REDRAW must not bump geometry_epoch" + ); + assert_eq!( + scene.block_epoch, block_before, + "REDRAW must not bump block_epoch" + ); } #[test] @@ -12257,13 +12216,22 @@ mod redraw_tests { scene.request_refresh(ViewportRefreshScope::Active); assert!(scene.refresh_consume(t0), "Active targeted tile 0"); - assert!(!scene.refresh_pending_any(), "Active consumed: set now empty"); + assert!( + !scene.refresh_pending_any(), + "Active consumed: set now empty" + ); // A subsequent All must re-establish membership even after the Active // request was fully drained (the superset is resolved at request time). scene.request_refresh(ViewportRefreshScope::All); - assert!(scene.refresh_pending_any(), "All re-pends after Active was consumed"); - assert!(scene.refresh_consume(t0), "All must re-force tile 0 after Active drained"); + assert!( + scene.refresh_pending_any(), + "All re-pends after Active was consumed" + ); + assert!( + scene.refresh_consume(t0), + "All must re-force tile 0 after Active drained" + ); } #[test] @@ -12274,7 +12242,10 @@ mod redraw_tests { // FxHashSet design): every EXISTING tile id must be targeted and drained. scene.split_active_pane(false); // 2 tiles scene.split_active_pane(false); // 3 tiles - assert!(scene.model_tiles.borrow().len() >= 3, "split must have produced tiles"); + assert!( + scene.model_tiles.borrow().len() >= 3, + "split must have produced tiles" + ); scene.request_refresh(ViewportRefreshScope::All); for idx in 0..scene.model_tiles.borrow().len() { @@ -12284,10 +12255,16 @@ mod redraw_tests { "All must target EXISTING tile {idx}, not just tile 0" ); } - assert!(!scene.refresh_pending_any(), "All drained every existing tile"); + assert!( + !scene.refresh_pending_any(), + "All drained every existing tile" + ); // A tile index that does not exist must NOT be drained. let ghost = scene.instance_id_for(&tile_inst(99, false)); - assert!(!scene.refresh_consume(ghost), "nonexistent tile 99 has no membership"); + assert!( + !scene.refresh_consume(ghost), + "nonexistent tile 99 has no membership" + ); } } @@ -12301,7 +12278,8 @@ mod selection_arc_tests { let tn = std::any::type_name_of_val(&scene.selection); assert!( tn.contains("Arc"), - "selection should be Arc, got {}", tn + "selection should be Arc, got {}", + tn ); } @@ -12429,12 +12407,36 @@ mod layout_cache_tests { // Min X = 0.0, Max X = 400.0 // Min Y = -200.0, Max Y = 100.0 // Min Z = 0.0, Max Z = 20.0 - assert!((bounds.0.x - 0.0).abs() < 1e-3, "min.x mismatch: {}", bounds.0.x); - assert!((bounds.1.x - 400.0).abs() < 1e-3, "max.x mismatch: {}", bounds.1.x); - assert!((bounds.0.y - (-200.0)).abs() < 1e-3, "min.y mismatch: {}", bounds.0.y); - assert!((bounds.1.y - 100.0).abs() < 1e-3, "max.y mismatch: {}", bounds.1.y); - assert!((bounds.0.z - 0.0).abs() < 1e-3, "min.z mismatch: {}", bounds.0.z); - assert!((bounds.1.z - 20.0).abs() < 1e-3, "max.z mismatch: {}", bounds.1.z); + assert!( + (bounds.0.x - 0.0).abs() < 1e-3, + "min.x mismatch: {}", + bounds.0.x + ); + assert!( + (bounds.1.x - 400.0).abs() < 1e-3, + "max.x mismatch: {}", + bounds.1.x + ); + assert!( + (bounds.0.y - (-200.0)).abs() < 1e-3, + "min.y mismatch: {}", + bounds.0.y + ); + assert!( + (bounds.1.y - 100.0).abs() < 1e-3, + "max.y mismatch: {}", + bounds.1.y + ); + assert!( + (bounds.0.z - 0.0).abs() < 1e-3, + "min.z mismatch: {}", + bounds.0.z + ); + assert!( + (bounds.1.z - 20.0).abs() < 1e-3, + "max.z mismatch: {}", + bounds.1.z + ); } #[test] @@ -12495,13 +12497,17 @@ mod layout_cache_tests { let l_h = s.add_entity(EntityType::Line(line)); // Create block and place insert at (200, 300, 0) - let xform = acadrust::types::Transform::from_translation(acadrust::types::Vector3::new(200.0, 300.0, 0.0)); - let _ = s.create_block_from_entities( - &[c_h, a_h, l_h], - "TEST_ANALYTICAL_BLOCK", - &acadrust::types::Transform::identity(), - &xform, - ).unwrap(); + let xform = acadrust::types::Transform::from_translation(acadrust::types::Vector3::new( + 200.0, 300.0, 0.0, + )); + let _ = s + .create_block_from_entities( + &[c_h, a_h, l_h], + "TEST_ANALYTICAL_BLOCK", + &acadrust::types::Transform::identity(), + &xform, + ) + .unwrap(); let wires = s.model_tile_wires_arc(0, &Camera::default(), 1.0, 1000.0); let depths = rustc_hash::FxHashMap::default(); diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index e5aed1d5d..85e40c6d7 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -24,10 +24,7 @@ pub enum PropValue { /// Editable text plus a dropdown of existing options (block reference /// Name row): picking an option re-points the reference, submitting a /// new name renames the definition. - EditChoice { - value: String, - options: Vec, - }, + EditChoice { value: String, options: Vec }, /// ACI/RGB/ByLayer/ByBlock color — rendered as a color picker. ColorChoice(AcadColor), /// Color-book color with its file-provided display name. @@ -51,7 +48,10 @@ pub enum PropValue { BoolToggle { field: &'static str, value: bool }, /// A 0-based index navigated with ◀ / ▶ buttons (e.g. a polyline's Current /// Vertex). `display` is the label shown between the arrows (e.g. "2 / 7"). - Stepper { field: &'static str, display: String }, + Stepper { + field: &'static str, + display: String, + }, /// Hatch pattern name — rendered as a combo_box from the catalog. HatchPatternChoice(String), /// Block attribute value keyed by its (dynamic, runtime) tag — rendered as @@ -59,11 +59,14 @@ pub enum PropValue { /// tag carried here, not the row's `&'static str` field. AttrText { tag: String, value: String }, /// A clickable link to one or more entities — the Constraints section's - /// row for one persistent sketch constraint. Clicking it selects every + /// row for one parametric constraint. Clicking it selects every /// entity in `handles` in the viewport. `conflicting` mirrors - /// `SketchConstraintSet::conflicts`, tinting the row the same danger + /// `ParametricConstraintSet::conflicts`, tinting the row the same danger /// color the constraint's glyph pill already uses. - EntityLink { handles: Vec, conflicting: bool }, + EntityLink { + handles: Vec, + conflicting: bool, + }, /// One row of the document-wide named-parameter table (Parameters /// section, shown when nothing is selected). `index` is the row's /// position in `ParameterTable::iter()` order — stable across edits to diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 0de4ad2e7..47b605db8 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -9,9 +9,7 @@ struct TextOrient { x_scale: f64, } -fn inverse_affine( - transform: &acadrust::types::Transform, -) -> Option { +fn inverse_affine(transform: &acadrust::types::Transform) -> Option { use acadrust::types::{Matrix3, Matrix4, Transform, Vector3}; let matrix = &transform.matrix.m; let linear = Matrix3::from_rows( @@ -248,9 +246,7 @@ impl Scene { let image_bearing = self.document.get_entity(h).is_some_and(|entity| { matches!( entity, - EntityType::RasterImage(_) - | EntityType::Ole2Frame(_) - | EntityType::Underlay(_) + EntityType::RasterImage(_) | EntityType::Ole2Frame(_) | EntityType::Underlay(_) ) }); if image_bearing { @@ -301,10 +297,7 @@ impl Scene { /// Entities in anonymous dimension blocks that visually belong to /// dimensions inside `block_record`. They must follow a block-coordinate /// reframe and be included in BEDIT's discard snapshot. - pub(crate) fn block_definition_dependent_handles( - &self, - block_record: Handle, - ) -> Vec { + pub(crate) fn block_definition_dependent_handles(&self, block_record: Handle) -> Vec { let Some(record) = self .document .block_records @@ -367,17 +360,16 @@ impl Scene { let owned: HashSet = handles.iter().copied().collect(); let transform = EntityTransform::Affine(*local_from_old); let matrix = &local_from_old.matrix.m; - let dependent_transform = - EntityTransform::Affine(acadrust::types::Transform::from_matrix( - acadrust::types::Matrix4 { - m: [ - [matrix[0][0], matrix[0][1], matrix[0][2], 0.0], - [matrix[1][0], matrix[1][1], matrix[1][2], 0.0], - [matrix[2][0], matrix[2][1], matrix[2][2], 0.0], - [0.0, 0.0, 0.0, 1.0], - ], - }, - )); + let dependent_transform = EntityTransform::Affine(acadrust::types::Transform::from_matrix( + acadrust::types::Matrix4 { + m: [ + [matrix[0][0], matrix[0][1], matrix[0][2], 0.0], + [matrix[1][0], matrix[1][1], matrix[1][2], 0.0], + [matrix[2][0], matrix[2][1], matrix[2][2], 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + }, + )); let mut changed = Vec::new(); for handle in handles { @@ -596,10 +588,7 @@ impl Scene { let entity = self.document.get_entity(h)?.clone(); let annotation_scales = if leader_pair_handles.contains(&h) { - crate::scene::annotative::annotation_scale_handles_for_entity( - &self.document, - h, - ) + crate::scene::annotative::annotation_scale_handles_for_entity(&self.document, h) } else { Vec::new() }; @@ -691,8 +680,8 @@ impl Scene { if let Some(model) = new_model { self.hatches.insert(h, model); } - let rebuilt_history = self.copy_solid_history(src_handle, h) - && self.transform_solid_history(h, t); + let rebuilt_history = + self.copy_solid_history(src_handle, h) && self.transform_solid_history(h, t); if !rebuilt_history && self.document.get_entity(h).is_some_and(|entity| { matches!( @@ -717,8 +706,7 @@ impl Scene { let leader_links: Vec<(Handle, Handle)> = handle_map .iter() .filter_map(|(&source_handle, &copied_handle)| { - let EntityType::Leader(source_leader) = - self.document.get_entity(source_handle)? + let EntityType::Leader(source_leader) = self.document.get_entity(source_handle)? else { return None; }; @@ -733,9 +721,7 @@ impl Scene { .collect(); for (leader_handle, annotation_handle) in leader_links { - if let Some(EntityType::Leader(leader)) = - self.document.get_entity_mut(leader_handle) - { + if let Some(EntityType::Leader(leader)) = self.document.get_entity_mut(leader_handle) { leader.annotation_handle = annotation_handle; } @@ -753,7 +739,7 @@ impl Scene { .collect(); self.bump_entities(&changes); // Constraints wholly within the duplicated selection follow it. - self.duplicate_sketch_constraints_for(&handle_map); + self.duplicate_parametric_constraints_for(&handle_map); self.refresh_meshes_for_handles(&refresh_solid_handles); new_handles } @@ -840,11 +826,7 @@ impl Scene { let Some(reference) = reference else { return; }; - let point = acadrust::types::Vector3::new( - reference.x, - reference.y, - reference.z, - ); + let point = acadrust::types::Vector3::new(reference.x, reference.y, reference.z); match self.document.get_entity_mut(handle) { Some(EntityType::Solid3D(entity)) => entity.point_of_reference = point, Some(EntityType::Surface(entity)) => entity.point_of_reference = point, @@ -928,7 +910,9 @@ impl Scene { (EntityType::Surface(_), SolidHistoryOperation::Loft(value)) => { let EntityType::Surface(surface) = crate::scene::model::loft_command_model::surface_entity(value) - else { return None; }; + else { + return None; + }; Some(Some(surface.surface_data)) } (EntityType::Solid3D(_), _) => Some(None), @@ -953,9 +937,11 @@ impl Scene { let Some(display) = self.prepare_solid_model_display(handle, &body) else { return false; }; - if matches!(&operation, acadrust::objects::SolidHistoryOperation::Loft(_) - | acadrust::objects::SolidHistoryOperation::Extrusion(_)) - && !display.0.complete + if matches!( + &operation, + acadrust::objects::SolidHistoryOperation::Loft(_) + | acadrust::objects::SolidHistoryOperation::Extrusion(_) + ) && !display.0.complete { return false; } @@ -970,7 +956,8 @@ impl Scene { match self.document.get_entity_mut(handle) { Some(EntityType::Solid3D(entity)) => entity.set_sat_document(&document), Some(EntityType::Surface(entity)) => { - entity.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string()); + entity.acis_data = + acadrust::entities::AcisData::from_sat(&document.to_sat_string()); if let Some(data) = surface_data { if matches!(&data, acadrust::entities::SurfaceData::Extruded { .. }) { entity.kind = acadrust::entities::SurfaceKind::Extruded; @@ -1001,16 +988,19 @@ impl Scene { let Some(display) = self.prepare_solid_model_display(handle, &body) else { return false; }; - if matches!(&operation, acadrust::objects::SolidHistoryOperation::Loft(_) - | acadrust::objects::SolidHistoryOperation::Extrusion(_)) - && !display.0.complete + if matches!( + &operation, + acadrust::objects::SolidHistoryOperation::Loft(_) + | acadrust::objects::SolidHistoryOperation::Extrusion(_) + ) && !display.0.complete { return false; } match self.document.get_entity_mut(handle) { Some(EntityType::Solid3D(entity)) => entity.set_sat_document(&document), Some(EntityType::Surface(entity)) => { - entity.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string()); + entity.acis_data = + acadrust::entities::AcisData::from_sat(&document.to_sat_string()); if let Some(data) = surface_data { if matches!(&data, acadrust::entities::SurfaceData::Extruded { .. }) { entity.kind = acadrust::entities::SurfaceKind::Extruded; @@ -1062,24 +1052,22 @@ impl Scene { true } - fn transform_solid_history( - &mut self, - handle: Handle, - transform: &EntityTransform, - ) -> bool { + fn transform_solid_history(&mut self, handle: Handle, transform: &EntityTransform) -> bool { let Some(mut operation) = self.document.solid_history_operation(handle).cloned() else { return false; }; - if !crate::scene::model::solid_history::transform_operation( - &mut operation, - transform, - ) { + if !crate::scene::model::solid_history::transform_operation(&mut operation, transform) { return false; } if let EntityTransform::Translate(delta) = transform { let surface_data = match (&operation, self.document.get_entity(handle)) { - (acadrust::objects::SolidHistoryOperation::Extrusion(value), Some(EntityType::Surface(_))) => { - let Some(data) = crate::scene::model::solid_history::extrusion_surface_data(value) else { + ( + acadrust::objects::SolidHistoryOperation::Extrusion(value), + Some(EntityType::Surface(_)), + ) => { + let Some(data) = + crate::scene::model::solid_history::extrusion_surface_data(value) + else { return false; }; Some(data) @@ -1104,12 +1092,7 @@ impl Scene { self.rebuild_solid_history(handle, operation) } - pub fn apply_solid_history_choice( - &mut self, - handle: Handle, - field: &str, - value: &str, - ) -> bool { + pub fn apply_solid_history_choice(&mut self, handle: Handle, field: &str, value: &str) -> bool { self.record_solid_history_before(handle); let mut created = false; if self.document.solid_history_graph(handle).is_none() { @@ -1137,7 +1120,10 @@ impl Scene { _ => return false, }; if create { - if !matches!(self.document.get_entity(handle), Some(EntityType::Solid3D(_))) { + if !matches!( + self.document.get_entity(handle), + Some(EntityType::Solid3D(_)) + ) { return false; } self.restore_solid_models(&[handle]); @@ -1195,13 +1181,9 @@ impl Scene { return false; }; self.restore_solid_models(&[handle]); - let Some(definition) = self - .solid_models - .get(&handle) - .and_then(|body| { - crate::scene::model::solid_history::fillet_radius_grip(body, radius) - }) - else { + let Some(definition) = self.solid_models.get(&handle).and_then(|body| { + crate::scene::model::solid_history::fillet_radius_grip(body, radius) + }) else { return false; }; let Some(axis) = definition.axis else { @@ -1247,8 +1229,7 @@ impl Scene { let acadrust::objects::SolidHistoryOperation::Chamfer(value) = &mut operation else { return false; }; - let distance = if grip_id - == crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE1 + let distance = if grip_id == crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE1 { &mut value.base_distance } else { @@ -1260,11 +1241,8 @@ impl Scene { } return self.preview_solid_history(handle, operation); } - if !crate::scene::model::solid_history::apply_primitive_grip( - &mut operation, - grip_id, - apply, - ) { + if !crate::scene::model::solid_history::apply_primitive_grip(&mut operation, grip_id, apply) + { return false; } self.preview_solid_history(handle, operation) @@ -1276,12 +1254,10 @@ impl Scene { field: &str, value: &str, ) -> bool { - let Some(mut operation) = - crate::scene::model::solid_history::primitive_property_operation( - &self.document, - handle, - ) - else { + let Some(mut operation) = crate::scene::model::solid_history::primitive_property_operation( + &self.document, + handle, + ) else { return false; }; if !crate::scene::model::solid_history::apply_primitive_property( @@ -1407,7 +1383,8 @@ impl Scene { }; let n = leader.vertices.len(); - if n < 3 || (grip_id != n - 1 && grip_id != n - 2) || leader.annotation_handle.is_null() { + if n < 3 || (grip_id != n - 1 && grip_id != n - 2) || leader.annotation_handle.is_null() + { return None; } @@ -1451,10 +1428,7 @@ impl Scene { if self.sync_displayed_annotation_context(annotation_handle) { self.poison_undo_recording(); } - self.bump_entities(&[( - annotation_handle, - crate::scene::ChangeKind::Modified, - )]); + self.bump_entities(&[(annotation_handle, crate::scene::ChangeKind::Modified)]); } } } @@ -1510,9 +1484,7 @@ impl Scene { let image_bearing = self.document.get_entity(handle).is_some_and(|entity| { matches!( entity, - EntityType::RasterImage(_) - | EntityType::Ole2Frame(_) - | EntityType::Underlay(_) + EntityType::RasterImage(_) | EntityType::Ole2Frame(_) | EntityType::Underlay(_) ) }); diff --git a/src/scene/named_parameters.rs b/src/scene/named_parameters.rs index 3682af689..da6327802 100644 --- a/src/scene/named_parameters.rs +++ b/src/scene/named_parameters.rs @@ -2,14 +2,13 @@ //! Formulas are parsed locally so dependency cycles can be rejected before evaluation. use super::Scene; -use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt; /// A parsed formula. Ordinary arithmetic plus [`Expr::Ref`] (a reference to /// another parameter by name) and [`Expr::Call`] (a function call). Only the /// small builtin set in `call_builtin` is accepted. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq)] pub enum Expr { Number(f64), Ref(String), @@ -57,7 +56,7 @@ impl Expr { /// stages, the UI) rather than panicking — matching this codebase's /// established pattern of isolating a bad derived value instead of letting /// it take down an unrelated computation (e.g. dangling-constraint handling -/// in `sketch_solve.rs`). +/// in `parametric_solve.rs`). #[derive(Debug, Clone, PartialEq)] pub enum ParamError { /// A formula's text doesn't parse. The `String` is a short, human- @@ -131,7 +130,7 @@ pub fn is_valid_name(name: &str) -> bool { } /// A dimensional constraint's literal or named driving value. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq)] pub enum DrivingValue { Literal(f64), Named(String), @@ -458,7 +457,7 @@ pub fn parse(source: &str) -> Result { /// One named parameter: a name plus its formula, both the original typed /// text (`source`, for redisplay/re-editing) and the parsed [`Expr`] /// (`expr`, for evaluation). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq)] pub struct Parameter { pub name: String, pub source: String, @@ -468,11 +467,11 @@ pub struct Parameter { /// Every named parameter for one document. /// /// `parameters` is intentionally not `pub` (unlike the sibling -/// `SketchConstraintSet::constraints`): this table's entire value is that +/// `ParametricConstraintSet::constraints`): this table's entire value is that /// every stored formula is already known to be parse-valid and cycle-free, /// an invariant only `set`/`remove` maintain. A direct external push could /// silently violate it. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct ParameterTable { parameters: Vec, } @@ -932,20 +931,6 @@ mod tests { assert_eq!(table.resolve("a"), Ok(2.0)); } - #[test] - fn table_round_trips_through_bincode() { - let mut table = ParameterTable::new(); - table.set("hole_dia", "5").unwrap(); - table.set("hole_spacing", "2 * hole_dia + 1.5").unwrap(); - - let bytes = bincode::serialize(&table).expect("serialize"); - let restored: ParameterTable = bincode::deserialize(&bytes).expect("deserialize"); - - assert_eq!(restored.len(), 2); - assert_eq!(restored.resolve("hole_spacing"), Ok(11.5)); - assert_eq!(restored.get("hole_dia").unwrap().source, "5"); - } - #[test] fn iter_preserves_insertion_order_across_redefinitions() { let mut table = ParameterTable::new(); diff --git a/src/scene/named_parameters_persist.rs b/src/scene/named_parameters_persist.rs deleted file mode 100644 index 986827ccb..000000000 --- a/src/scene/named_parameters_persist.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Persists the drawing-wide named-parameter table in a versioned XRecord. - -use super::named_parameters::ParameterTable; -use super::Scene; -use bincode::Options; - -const XRECORD_KEY: &str = "OCS_NAMED_PARAMETERS"; - -/// See `sketch_persist::MAX_CHUNK_BYTES`'s doc comment: the vendored DWG -/// writer truncates any single `XRecordValue::Chunk` entry to `u8::MAX` -/// bytes, so a table past a handful of parameters must be split across -/// multiple same-code `310` entries on write and concatenated back on read. -const MAX_CHUNK_BYTES: usize = u8::MAX as usize; -const MAX_RECORD_BYTES: usize = 8 * 1024 * 1024; - -/// Prefixed onto the serialized blob so a future schema change can be -/// detected and gracefully skipped rather than silently misreading bytes — -/// same call-out as `sketch_persist::FORMAT_VERSION`, same caveat that the -/// migration path itself is still undesigned. -const FORMAT_VERSION: u8 = 1; - -fn encode(table: &ParameterTable) -> Option> { - let mut bytes = vec![FORMAT_VERSION]; - let body = bincode::serialize(table).ok()?; - if body.len() >= MAX_RECORD_BYTES { - return None; - } - bytes.extend(body); - Some(bytes) -} - -fn decode(bytes: &[u8]) -> Option { - let (&version, body) = bytes.split_first()?; - if version != FORMAT_VERSION { - return None; - } - bincode::DefaultOptions::new() - .with_fixint_encoding() - .allow_trailing_bytes() - .with_limit(MAX_RECORD_BYTES as u64) - .deserialize(body) - .ok() -} - -impl Scene { - /// Writes `named_parameters` into the document's `OCS_NAMED_PARAMETERS` - /// XRecord, ready for whatever save call happens next to serialize - /// `self.document` as-is. Called from the same save entry points as - /// `materialize_sketch_constraints_for_save` - /// (`OpenCADStudio::prepare_native_save`, the wasm save path). - /// - /// An empty table is skipped rather than writing an empty XRecord — an - /// unconstrained-by-parameters drawing (the common case, at least until - /// this feature is used) should not gain persisted-but-empty - /// bookkeeping on every save. - pub(crate) fn materialize_named_parameters_for_save(&mut self) { - if self.named_parameters.is_empty() { - return; - } - let Some(bytes) = encode(&self.named_parameters) else { - return; - }; - let owner = self.document.header.model_space_block_handle; - if owner.is_null() { - return; - } - self.document.ensure_xrecord(owner, XRECORD_KEY); - if let Some(record) = self.document.xrecord_mut(owner, XRECORD_KEY) { - // Overwrite, not append: a resave must replace the prior blob, - // not accumulate more Chunk entries every time. - record.entries.clear(); - for chunk in bytes.chunks(MAX_CHUNK_BYTES) { - record.entries.push(acadrust::objects::XRecordEntry::new( - 310, - acadrust::objects::XRecordValue::Chunk(chunk.to_vec()), - )); - } - } - } - - /// Populates `named_parameters` from the just-loaded `self.document`'s - /// `OCS_NAMED_PARAMETERS` XRecord, if any — called right after a - /// document open installs its `CadDocument` into this `Scene`, the same - /// call sites `load_sketch_constraints_from_document` already has - /// (`OpenCADStudio::on_file_opened`, the automation `"open"`/`"new"` - /// ops). Replaces whatever was already in `named_parameters` (a fresh - /// table for a real open; `"new"`'s blank document has nothing to find - /// anyway). - pub(crate) fn load_named_parameters_from_document(&mut self) { - self.named_parameters = ParameterTable::new(); - let owner = self.document.header.model_space_block_handle; - if owner.is_null() { - return; - } - let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { - return; - }; - // Concatenate every Chunk entry in order, not just the first — see - // `MAX_CHUNK_BYTES`'s doc comment. - let mut bytes = Vec::new(); - for entry in &record.entries { - if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value { - if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES { - bytes.clear(); - break; - } - bytes.extend_from_slice(chunk); - } - } - if bytes.is_empty() { - return; - } - if let Some(table) = decode(&bytes) { - self.named_parameters = table; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_parameter_table_survives_materialize_and_reload() { - let mut scene = Scene::new(); - scene.named_parameters_mut().set("hole_dia", "5").unwrap(); - scene - .named_parameters_mut() - .set("hole_spacing", "2 * hole_dia + 1.5") - .unwrap(); - - scene.materialize_named_parameters_for_save(); - // Simulate the load-time side of a save/reload round trip: drop the - // in-memory table (a fresh open starts empty) and repopulate purely - // from what materialize just wrote into `document.objects`. - scene.named_parameters = ParameterTable::new(); - scene.load_named_parameters_from_document(); - - assert_eq!(scene.named_parameters().len(), 2); - assert_eq!(scene.named_parameters().resolve("hole_spacing"), Ok(11.5)); - assert_eq!( - scene.named_parameters().get("hole_dia").unwrap().source, - "5" - ); - } - - /// The above test only proves `materialize`/`load` agree with each - /// other on the same in-memory `CadDocument` — this drives a real - /// `save_to_bytes`/`load_bytes` round trip (the same primitives - /// `OpenCADStudio::on_file_opened`'s native save/open path uses), - /// through both supported formats, mirroring - /// `sketch_persist`'s own real-bytes tests. - fn full_bytes_roundtrip(ext: &str) -> ParameterTable { - let mut scene = Scene::new(); - scene - .named_parameters_mut() - .set("plate_len", "42.5") - .unwrap(); - scene - .named_parameters_mut() - .set("plate_width", "plate_len / 2") - .unwrap(); - - scene.materialize_named_parameters_for_save(); - let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) - .unwrap_or_else(|e| panic!("save to {ext} bytes: {e}")); - let mut reloaded_scene = Scene::new(); - reloaded_scene.document = - crate::io::load_bytes(&format!("named_params_roundtrip.{ext}"), bytes) - .unwrap_or_else(|e| panic!("reload {ext} bytes: {e}")); - reloaded_scene.load_named_parameters_from_document(); - - reloaded_scene.named_parameters - } - - #[test] - fn a_parameter_table_survives_a_real_dxf_save_and_load_round_trip() { - let restored = full_bytes_roundtrip("dxf"); - assert_eq!(restored.len(), 2); - assert_eq!(restored.resolve("plate_width"), Ok(21.25)); - } - - #[test] - fn a_parameter_table_survives_a_real_dwg_save_and_load_round_trip() { - let restored = full_bytes_roundtrip("dwg"); - assert_eq!(restored.len(), 2); - assert_eq!(restored.resolve("plate_width"), Ok(21.25)); - } - - #[test] - fn an_empty_table_is_not_materialized() { - let mut scene = Scene::new(); - scene.materialize_named_parameters_for_save(); - let owner = scene.document.header.model_space_block_handle; - assert!( - scene.document.xrecord(owner, XRECORD_KEY).is_none(), - "an empty table should not create an XRecord" - ); - } - - #[test] - fn decode_rejects_a_mismatched_format_version() { - let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)]; - bytes.extend(bincode::serialize(&ParameterTable::new()).unwrap()); - assert!( - decode(&bytes).is_none(), - "a future/unknown format version must be rejected, not misread" - ); - } -} diff --git a/src/scene/sketch_constraints.rs b/src/scene/parametric_constraints.rs similarity index 63% rename from src/scene/sketch_constraints.rs rename to src/scene/parametric_constraints.rs index 661c9ab9a..fe55c5dc4 100644 --- a/src/scene/sketch_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -1,8 +1,7 @@ -//! Persistent sketch-constraint data types and scope management. +//! Runtime parametric-constraint data and scope management. use super::named_parameters::DrivingValue; use acadrust::types::{Handle, Vector3}; -use serde::{Deserialize, Serialize}; /// One endpoint a constraint attaches to: an entity plus which sub-element /// of it. @@ -14,11 +13,11 @@ use serde::{Deserialize, Serialize}; /// [`dimension_assoc::source_points`](super::dimension_assoc::source_points)'s /// ordered per-entity-type point list when non-negative (0/1 = a line's /// start/end, ...), or names a special case when negative (-3 = a -/// circle/arc's center; -2 = a point at a stored parameter, not currently -/// used by constraint endpoints but reserved for consistency with the -/// dimension scheme). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct SketchRef { +/// circle/arc's center; -2 = a bounded curve's midpoint). Polyline segment +/// and segment-midpoint references use private negative ranges so they stay +/// distinct from vertex markers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ParametricRef { pub entity: Handle, /// `None` addresses the entity as a whole — what a Radius, Length, or /// whole-curve constraint (Parallel, Perpendicular, Equal, Horizontal, @@ -28,8 +27,9 @@ pub struct SketchRef { } const POLYLINE_SEGMENT_MARKER_BASE: i32 = -1_000_000; +const POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE: i32 = -2_000_000; -impl SketchRef { +impl ParametricRef { pub fn whole(entity: Handle) -> Self { Self { entity, @@ -64,9 +64,23 @@ impl SketchRef { pub fn segment_index(self) -> Option { let marker = self.marker?; - (marker <= POLYLINE_SEGMENT_MARKER_BASE) + (marker <= POLYLINE_SEGMENT_MARKER_BASE && marker > POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE) .then(|| (POLYLINE_SEGMENT_MARKER_BASE - marker) as usize) } + + /// Select the midpoint of one straight polyline segment. + pub fn segment_midpoint(entity: Handle, index: usize) -> Self { + Self { + entity, + marker: Some(POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - index as i32), + } + } + + pub fn segment_midpoint_index(self) -> Option { + let marker = self.marker?; + (marker <= POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE) + .then(|| (POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - marker) as usize) + } } /// The friendly, user-facing constraint types — the "what button did they @@ -76,7 +90,7 @@ impl SketchRef { /// tools are (`crate::modules::parametric::tools`), plus the /// endpoint-picking kinds (`Coincident`, `Radius`, `Tangent`) that one-shot /// group never needed. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq)] pub enum ConstraintKind { Coincident, Horizontal, @@ -91,12 +105,14 @@ pub enum ConstraintKind { /// selected entity. Distance, Angle, + /// Angle defined by two rays sharing the middle point. + Angle3Point, Radius, Tangent, - /// An open spline endpoint remains curvature-continuous with another curve endpoint. + /// An open spline endpoint remains curvature-continuous with another bounded curve endpoint. Smooth, /// Two circles/arcs share a center — `refs`: `[center(a), center(b)]`. - /// Solves identically to `Coincident` (`sketch_solve.rs` broadens that + /// Solves identically to `Coincident` (`parametric_solve.rs` broadens that /// match arm rather than duplicating it) — only the DWG-native class /// name (`ACCONCENTRICCONSTRAINT` vs `ACPOINTCOINCIDENCECONSTRAINT`) /// and the UI entry point differ. @@ -111,7 +127,7 @@ pub enum ConstraintKind { Midpoint, /// Locks a whole entity at its current position — `refs`: `[whole(entity)]`. /// No `driving_param`: the target is the entity's own live geometry at - /// solve time, not a typed value (see `sketch_solve.rs`'s `Fixed` arm). + /// solve time, not a typed value (see `parametric_solve.rs`'s `Fixed` arm). Fixed, /// A point lies anywhere along a line's or circle's curve (not /// restricted to an endpoint/center) — `refs`: `[point, whole(entity)]`. @@ -139,6 +155,9 @@ pub enum ConstraintKind { /// `DirectionType` set to a fixed `(1,0,0)`/`(0,1,0)` direction. DistanceX, DistanceY, + /// Signed point-to-point distance along an arbitrary fixed direction, + /// or along a direction derived from a third line reference. + DistanceDirected, /// A line perpendicular to a circle/arc's tangent at their point of /// contact — `refs`: `[whole(a), whole(b)]`, either order. For the /// Line/Circle-only entity model this is equivalent to "the line @@ -148,43 +167,68 @@ pub enum ConstraintKind { /// `Perpendicular` (line-to-line only). Line-Line has no meaning here /// (that's plain `Perpendicular`) and isn't buildable. Normal, - /// An arc's arc length (`radius * sweep angle`) — `refs`: - /// `[whole(arc)]`. Solves against the arc's own `start_angle`/ - /// `end_angle`, registered separately from its center/radius - /// (`sketch_solve.rs`'s `arc_angles` cache) — no native DWG - /// representation exists for this (neither `AcExplicitConstr.h` nor - /// `acadrust`'s `AssocConstraintNodeData` has an ArcLength-shaped - /// class/variant), so it persists in this app's own XRecord format - /// only, the same "DWG can't carry everything" gap the dependency-chain - /// DXF-omission precedent already documents. - ArcLength, + /// A standard rigid geometry container. Its referenced points keep their + /// original relative distances while the whole set may translate or rotate. + RigidSet, } pub type ConstraintId = u32; -/// One persisted constraint record: a friendly [`ConstraintKind`], the +pub(crate) mod distance_direction_type { + pub const UNDIRECTED: u8 = 0; + pub const FIXED: u8 = 1; + pub const PARALLEL_TO_LINE: u8 = 2; + pub const PERPENDICULAR_TO_LINE: u8 = 3; +} + +pub(crate) mod angle_sector { + pub const PARALLEL_COUNTERCLOCKWISE: u8 = 0; + pub const ANTIPARALLEL_CLOCKWISE: u8 = 1; + pub const PARALLEL_CLOCKWISE: u8 = 2; + pub const ANTIPARALLEL_COUNTERCLOCKWISE: u8 = 3; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct NativeConstraintOrigin { + pub(crate) group: Handle, + pub(crate) node: i32, +} + +/// One runtime constraint: a friendly [`ConstraintKind`], the /// entities/points it relates, and — for a dimensional kind — the value /// driving it. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SketchConstraint { +#[derive(Debug, Clone, PartialEq)] +pub struct ParametricConstraint { pub id: ConstraintId, pub kind: ConstraintKind, - pub refs: Vec, + pub refs: Vec, /// The target for a dimensional constraint (a `Distance`'s length, an /// `Angle`'s degrees, a `Radius`'s radius) — a literal number or a /// named-parameter reference resolved through `Scene::named_parameters` at solve time by - /// `sketch_solve::build_constraint`). `None` for every purely-geometric + /// `parametric_solve::build_constraint`). `None` for every purely-geometric /// kind (Coincident, Horizontal, Vertical, Parallel, Perpendicular, /// Equal, Tangent). pub driving_param: Option, /// Lets a user suppress a constraint without losing it — a re-solve /// skips a disabled constraint entirely. pub enabled: bool, + /// Standard graph node retained in place because its group also contains + /// graph features that are not safe to rebuild independently. + pub(crate) native_origin: Option, + /// Original point positions for a standard rigid set. This is rebuilt + /// from the associative graph on open and never stored separately. + pub(crate) rigid_points: Vec<(ParametricRef, Vector3)>, + /// Standard distance direction mode and vector. A third entry in `refs` + /// identifies the direction line for the line-relative modes. + pub(crate) distance_direction_type: u8, + pub(crate) distance_direction: Option, + /// Which of the four directed sectors an angular constraint measures. + pub(crate) angle_sector: u8, } /// A constraint scope: model space or one block definition. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum SketchScope { +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ParametricScope { ModelSpace, /// A block definition's `BlockRecord` handle — matches /// `BlockEditSession::br_handle` @@ -192,49 +236,55 @@ pub enum SketchScope { Block(Handle), } -impl SketchScope { +impl ParametricScope { /// The owner handle under which this scope is persisted. pub fn owner_handle(&self, document: &acadrust::CadDocument) -> Handle { match self { - SketchScope::ModelSpace => document.header.model_space_block_handle, - SketchScope::Block(handle) => *handle, + ParametricScope::ModelSpace => document.header.model_space_block_handle, + ParametricScope::Block(handle) => *handle, } } } -/// Every persisted constraint for one [`SketchScope`]. Solver state is rebuilt +/// Every constraint decoded from one standard graph scope. Solver state is rebuilt /// on demand and is not stored here. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SketchConstraintSet { - pub scope: SketchScope, - pub constraints: Vec, +#[derive(Debug, Clone)] +pub struct ParametricConstraintSet { + pub scope: ParametricScope, + pub constraints: Vec, next_id: ConstraintId, + /// Parameters owned by this standard block scope. Model-space and + /// command-created constraints continue to use the drawing parameter + /// table; block definitions need their own namespace because identical + /// parameter names may legitimately occur in different blocks. + pub(crate) local_parameters: super::named_parameters::ParameterTable, + pub(crate) retained_standard_groups: Vec, /// Cached total remaining degrees of freedom, summed across every /// independent solve partition in this scope — updated by - /// `sketch_solve::solve_scope` each time this set is resolved. `None` + /// `parametric_solve::solve_scope` each time this set is resolved. `None` /// until the first resolve (e.g. right after loading from disk, before /// any edit has touched this scope yet). The status badge reads this /// rather than recomputing it every frame. Not persisted: /// it's a derived cache, not real constraint state. - #[serde(skip)] pub dof: Option, /// Cached redundant/conflicting constraints found by the last resolve. /// Also a derived cache, not /// persisted; a `ConflictResolverPanel` reads this rather than calling /// `cadkernel_constraints::diagnosis::classify_redundant` itself. - #[serde(skip)] pub conflicts: Vec<( ConstraintId, cadkernel_constraints::diagnosis::RedundancyKind, )>, } -impl SketchConstraintSet { - pub fn new(scope: SketchScope) -> Self { +impl ParametricConstraintSet { + pub fn new(scope: ParametricScope) -> Self { Self { scope, constraints: Vec::new(), next_id: 0, + local_parameters: super::named_parameters::ParameterTable::new(), + retained_standard_groups: Vec::new(), dof: None, conflicts: Vec::new(), } @@ -244,17 +294,22 @@ impl SketchConstraintSet { pub fn add( &mut self, kind: ConstraintKind, - refs: Vec, + refs: Vec, driving_param: Option, ) -> ConstraintId { let id = self.next_id; self.next_id += 1; - self.constraints.push(SketchConstraint { + self.constraints.push(ParametricConstraint { id, kind, refs, driving_param, enabled: true, + native_origin: None, + rigid_points: Vec::new(), + distance_direction_type: 0, + distance_direction: None, + angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, }); id } @@ -266,12 +321,15 @@ impl SketchConstraintSet { self.constraints.len() != before } - pub fn get(&self, id: ConstraintId) -> Option<&SketchConstraint> { + pub fn get(&self, id: ConstraintId) -> Option<&ParametricConstraint> { self.constraints.iter().find(|c| c.id == id) } /// Every enabled constraint referencing `entity`. - pub fn constraints_touching(&self, entity: Handle) -> impl Iterator { + pub fn constraints_touching( + &self, + entity: Handle, + ) -> impl Iterator { self.constraints .iter() .filter(move |c| c.enabled && c.refs.iter().any(|r| r.entity == entity)) @@ -289,14 +347,12 @@ impl SketchConstraintSet { } } -/// Resolves a [`SketchRef`] to its current world-space point, for building +/// Resolves a [`ParametricRef`] to its current world-space point, for building /// an `cadkernel_constraints` `ParamStore` from live document geometry — the constraint /// endpoint's equivalent of `dimension_assoc::resolve_reference`, restricted /// to the marker conventions constraint endpoints actually use (whole-entity -/// `None`, an ordinary `source_points()` index, or the `-3` center case). -/// Returns `None` for a dangling reference (handle doesn't resolve) or a -/// marker this scheme doesn't (yet) support (e.g. `-2`, or an out-of-range -/// index). +/// `None`, an ordinary `source_points()` index, the `-3` center case, or a +/// bounded curve/segment midpoint). /// /// Solver-side registration reads raw entity fields directly. This helper is /// for UI-side consumers that need the current world-space position. @@ -308,6 +364,36 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio _ => None, }; } + if marker == -2 { + let curve = crate::entities::curve::entity_curve(entity)?; + if curve.is_closed() { + return None; + } + let point = curve.point_at(0.5); + return Some(Vector3::new(point[0], point[1], point[2])); + } + let segment = ParametricRef { + entity: Handle::NULL, + marker: Some(marker), + } + .segment_midpoint_index(); + if let Some(segment) = segment { + let points = super::dimension_assoc::source_points(entity); + let closed = match entity { + acadrust::EntityType::LwPolyline(polyline) => polyline.is_closed, + acadrust::EntityType::Polyline2D(polyline) => polyline.is_closed(), + _ => return None, + }; + let a = *points.get(segment)?; + let b = if segment + 1 < points.len() { + points[segment + 1] + } else if closed { + *points.first()? + } else { + return None; + }; + return Some((a + b) * 0.5); + } if marker < 0 { return None; } @@ -317,27 +403,27 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio } /// Below this squared distance (1e-6 world units), two points count as -/// already coincident for [`nearest_sketch_point`]'s purposes. +/// already coincident for [`nearest_parametric_point`]'s purposes. const COINCIDENT_EPSILON_SQ: f64 = 1.0e-12; /// Finds the addressable entity point nearest a snapped world position. /// Returns `None` when no point in the scope is within the coincidence /// tolerance. -pub(crate) fn nearest_sketch_point( +pub(crate) fn nearest_parametric_point( document: &acadrust::CadDocument, - scope: SketchScope, + scope: ParametricScope, world_point: Vector3, exclude: Option, -) -> Option { +) -> Option { let owner = scope.owner_handle(document); - let mut best: Option<(f64, SketchRef)> = None; + let mut best: Option<(f64, ParametricRef)> = None; let mut consider = |handle: Handle, marker: i32, point: Vector3| { let dx = point.x - world_point.x; let dy = point.y - world_point.y; let dz = point.z - world_point.z; let dist_sq = dx * dx + dy * dy + dz * dz; if dist_sq <= COINCIDENT_EPSILON_SQ && best.as_ref().is_none_or(|(d, _)| dist_sq < *d) { - best = Some((dist_sq, SketchRef::point(handle, marker))); + best = Some((dist_sq, ParametricRef::point(handle, marker))); } }; for candidate in document.entities() { @@ -375,6 +461,7 @@ impl ConstraintKind { ConstraintKind::Equal => "=", ConstraintKind::Distance => "↔", ConstraintKind::Angle => "∠", + ConstraintKind::Angle3Point => "∠₃", ConstraintKind::Radius => "R", ConstraintKind::Tangent => "T", ConstraintKind::Smooth => "G²", @@ -389,15 +476,16 @@ impl ConstraintKind { ConstraintKind::Diameter => "⌀", ConstraintKind::DistanceX => "↔ₓ", ConstraintKind::DistanceY => "↔ᵧ", + ConstraintKind::DistanceDirected => "↗", ConstraintKind::Normal => "⊾", - ConstraintKind::ArcLength => "⌢", + ConstraintKind::RigidSet => "▣", } } } /// The full glyph text for one constraint: its symbol, plus the driving /// value for a dimensional kind (Distance/Angle/Radius). -pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String { +pub(crate) fn glyph_label(constraint: &ParametricConstraint) -> String { match (constraint.kind, &constraint.driving_param) { (ConstraintKind::Angle, Some(DrivingValue::Literal(value))) => { format!("{} {value:.1}°", constraint.kind.glyph_symbol()) @@ -422,7 +510,7 @@ pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String { /// World-space anchor and outward direction for a constraint glyph. pub(crate) fn glyph_placement( document: &acadrust::CadDocument, - constraint: &SketchConstraint, + constraint: &ParametricConstraint, ) -> Option<(Vector3, Vector3)> { let r = constraint.refs.first()?; let entity = document.get_entity(r.entity)?; @@ -434,19 +522,13 @@ pub(crate) fn glyph_placement( ) }; let line_normal = |line: &acadrust::entities::Line| { - let direction = Vector3::new( - -(line.end.y - line.start.y), - line.end.x - line.start.x, - 0.0, - ); + let direction = Vector3::new(-(line.end.y - line.start.y), line.end.x - line.start.x, 0.0); (direction.length_squared() > 1e-24) .then_some(direction) .unwrap_or(Vector3::UNIT_Y) }; match (entity, r.marker) { - (acadrust::EntityType::Line(line), None) => { - Some((line_midpoint(line), line_normal(line))) - } + (acadrust::EntityType::Line(line), None) => Some((line_midpoint(line), line_normal(line))), (acadrust::EntityType::Circle(circle), None | Some(-3)) => { let center = circle.center_wcs(); let anchor = circle.point_at_angle_wcs(0.0); @@ -486,52 +568,72 @@ pub(crate) fn glyph_placement( } impl super::Scene { - pub fn is_sketch_constraint_visible(&self, scope: SketchScope, id: ConstraintId) -> bool { - !self.hidden_sketch_constraints.contains(&(scope, id)) + pub fn is_parametric_constraint_visible( + &self, + scope: ParametricScope, + id: ConstraintId, + ) -> bool { + !self.hidden_parametric_constraints.contains(&(scope, id)) } - pub fn set_sketch_constraint_visibility( + pub fn set_parametric_constraint_visibility( &mut self, - scope: SketchScope, + scope: ParametricScope, handles: Option<&[Handle]>, dimensional: bool, visible: bool, ) -> usize { - let ids: Vec<_> = self.sketch_constraint_set(scope).into_iter() + let ids: Vec<_> = self + .parametric_constraint_set(scope) + .into_iter() .flat_map(|set| set.constraints.iter()) .filter(|constraint| constraint.driving_param.is_some() == dimensional) - .filter(|constraint| handles.is_none_or(|handles| constraint.refs.iter() - .any(|reference| handles.contains(&reference.entity)))) - .map(|constraint| constraint.id).collect(); + .filter(|constraint| { + handles.is_none_or(|handles| { + constraint + .refs + .iter() + .any(|reference| handles.contains(&reference.entity)) + }) + }) + .map(|constraint| constraint.id) + .collect(); for id in &ids { - if visible { self.hidden_sketch_constraints.remove(&(scope, *id)); } - else { self.hidden_sketch_constraints.insert((scope, *id)); } + if visible { + self.hidden_parametric_constraints.remove(&(scope, *id)); + } else { + self.hidden_parametric_constraints.insert((scope, *id)); + } } ids.len() } /// Infers relations already present in the selected geometry. - pub fn inferred_sketch_constraints( + pub fn inferred_parametric_constraints( &self, - scope: SketchScope, + scope: ParametricScope, handles: &[Handle], - ) -> Vec<(ConstraintKind, Vec)> { + ) -> Vec<(ConstraintKind, Vec)> { use cadkernel::geom2d::{ infer_constraints, Arc, Circle, ConstraintEndpoint, InferredConstraint, Line, - SketchPrimitive, Tolerance, + ParametricPrimitive, Tolerance, }; let mut sources = Vec::new(); for handle in handles { let primitive = match self.document.get_entity(*handle) { - Some(acadrust::EntityType::Line(line)) => SketchPrimitive::Line(Line { - start: [line.start.x, line.start.y], end: [line.end.x, line.end.y], + Some(acadrust::EntityType::Line(line)) => ParametricPrimitive::Line(Line { + start: [line.start.x, line.start.y], + end: [line.end.x, line.end.y], }), - Some(acadrust::EntityType::Circle(circle)) => SketchPrimitive::Circle(Circle { - centre: [circle.center.x, circle.center.y], radius: circle.radius, + Some(acadrust::EntityType::Circle(circle)) => ParametricPrimitive::Circle(Circle { + centre: [circle.center.x, circle.center.y], + radius: circle.radius, }), - Some(acadrust::EntityType::Arc(arc)) => SketchPrimitive::Arc(Arc { - centre: [arc.center.x, arc.center.y], radius: arc.radius, - start_angle: arc.start_angle, end_angle: arc.end_angle, + Some(acadrust::EntityType::Arc(arc)) => ParametricPrimitive::Arc(Arc { + centre: [arc.center.x, arc.center.y], + radius: arc.radius, + start_angle: arc.start_angle, + end_angle: arc.end_angle, }), _ => continue, }; @@ -542,54 +644,90 @@ impl super::Scene { ConstraintEndpoint::Start => 0, ConstraintEndpoint::End => 1, }; - let mut mapped: Vec<_> = infer_constraints( - &primitives, Tolerance::new(1e-6), 0.5_f64.to_radians(), - ).into_iter().map(|relation| match relation { - InferredConstraint::Coincident { first, first_endpoint, second, second_endpoint } => ( - ConstraintKind::Coincident, - vec![SketchRef::point(sources[first].0, marker(first_endpoint)), - SketchRef::point(sources[second].0, marker(second_endpoint))], - ), - InferredConstraint::Collinear { first, second } => ( - ConstraintKind::Colinear, - vec![SketchRef::whole(sources[first].0), SketchRef::whole(sources[second].0)], - ), - InferredConstraint::Concentric { first, second } => ( - ConstraintKind::Concentric, - vec![SketchRef::center(sources[first].0), SketchRef::center(sources[second].0)], - ), - InferredConstraint::Parallel { first, second } => ( - ConstraintKind::Parallel, - vec![SketchRef::whole(sources[first].0), SketchRef::whole(sources[second].0)], - ), - InferredConstraint::Perpendicular { first, second } => ( - ConstraintKind::Perpendicular, - vec![SketchRef::whole(sources[first].0), SketchRef::whole(sources[second].0)], - ), - InferredConstraint::Horizontal { entity } => ( - ConstraintKind::Horizontal, vec![SketchRef::whole(sources[entity].0)], - ), - InferredConstraint::Vertical { entity } => ( - ConstraintKind::Vertical, vec![SketchRef::whole(sources[entity].0)], - ), - InferredConstraint::Tangent { first, second } => ( - ConstraintKind::Tangent, - vec![SketchRef::whole(sources[first].0), SketchRef::whole(sources[second].0)], - ), - }).collect(); - if let Some(existing) = self.sketch_constraint_set(scope) { - mapped.retain(|(kind, refs)| !existing.constraints.iter().any(|constraint| { - constraint.kind == *kind && (constraint.refs == *refs - || (constraint.refs.len() == 2 && refs.len() == 2 - && constraint.refs[0] == refs[1] && constraint.refs[1] == refs[0])) - })); + let mut mapped: Vec<_> = + infer_constraints(&primitives, Tolerance::new(1e-6), 0.5_f64.to_radians()) + .into_iter() + .map(|relation| match relation { + InferredConstraint::Coincident { + first, + first_endpoint, + second, + second_endpoint, + } => ( + ConstraintKind::Coincident, + vec![ + ParametricRef::point(sources[first].0, marker(first_endpoint)), + ParametricRef::point(sources[second].0, marker(second_endpoint)), + ], + ), + InferredConstraint::Collinear { first, second } => ( + ConstraintKind::Colinear, + vec![ + ParametricRef::whole(sources[first].0), + ParametricRef::whole(sources[second].0), + ], + ), + InferredConstraint::Concentric { first, second } => ( + ConstraintKind::Concentric, + vec![ + ParametricRef::center(sources[first].0), + ParametricRef::center(sources[second].0), + ], + ), + InferredConstraint::Parallel { first, second } => ( + ConstraintKind::Parallel, + vec![ + ParametricRef::whole(sources[first].0), + ParametricRef::whole(sources[second].0), + ], + ), + InferredConstraint::Perpendicular { first, second } => ( + ConstraintKind::Perpendicular, + vec![ + ParametricRef::whole(sources[first].0), + ParametricRef::whole(sources[second].0), + ], + ), + InferredConstraint::Horizontal { entity } => ( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(sources[entity].0)], + ), + InferredConstraint::Vertical { entity } => ( + ConstraintKind::Vertical, + vec![ParametricRef::whole(sources[entity].0)], + ), + InferredConstraint::Tangent { first, second } => ( + ConstraintKind::Tangent, + vec![ + ParametricRef::whole(sources[first].0), + ParametricRef::whole(sources[second].0), + ], + ), + }) + .collect(); + if let Some(existing) = self.parametric_constraint_set(scope) { + mapped.retain(|(kind, refs)| { + !existing.constraints.iter().any(|constraint| { + constraint.kind == *kind + && (constraint.refs == *refs + || (constraint.refs.len() == 2 + && refs.len() == 2 + && constraint.refs[0] == refs[1] + && constraint.refs[1] == refs[0])) + }) + }); } - mapped.retain(|(kind, refs)| self.validate_sketch_constraint(*kind, refs, None).is_ok()); + mapped.retain(|(kind, refs)| { + self.validate_parametric_constraint(*kind, refs, None) + .is_ok() + }); mapped } - pub fn smooth_constraint_refs(&self, handles: &[Handle]) -> Option> { - let [first, second] = handles else { return None }; + pub fn smooth_constraint_refs(&self, handles: &[Handle]) -> Option> { + let [first, second] = handles else { + return None; + }; let first_entity = self.document.get_entity(*first)?; let second_entity = self.document.get_entity(*second)?; let (spline_handle, spline, target_handle, target) = match (first_entity, second_entity) { @@ -597,9 +735,11 @@ impl super::Scene { (target, acadrust::EntityType::Spline(spline)) => (*second, spline, *first, target), _ => return None, }; - if spline.flags.closed || spline.flags.periodic { return None; } - let spline_points = super::dimension_assoc::source_points( - &acadrust::EntityType::Spline(spline.clone())); + if spline.flags.closed || spline.flags.periodic { + return None; + } + let spline_points = + super::dimension_assoc::source_points(&acadrust::EntityType::Spline(spline.clone())); let target_points = super::dimension_assoc::source_points(target); let spline_ends = [*spline_points.first()?, *spline_points.last()?]; let target_ends = [*target_points.first()?, *target_points.last()?]; @@ -607,36 +747,46 @@ impl super::Scene { for (source_marker, source) in spline_ends.iter().enumerate() { for (target_marker, target) in target_ends.iter().enumerate() { let distance = (*source - *target).length_squared(); - if distance < best.0 { best = (distance, source_marker, target_marker); } + if distance < best.0 { + best = (distance, source_marker, target_marker); + } } } Some(vec![ - SketchRef::point(spline_handle, best.1 as i32), - SketchRef::point(target_handle, best.2 as i32), + ParametricRef::point(spline_handle, best.1 as i32), + ParametricRef::point(target_handle, best.2 as i32), ]) } /// The constraint set for `scope`, if one has been created. - pub fn sketch_constraint_set(&self, scope: SketchScope) -> Option<&SketchConstraintSet> { - self.sketch_constraints.iter().find(|s| s.scope == scope) + pub fn parametric_constraint_set( + &self, + scope: ParametricScope, + ) -> Option<&ParametricConstraintSet> { + self.parametric_constraints + .iter() + .find(|s| s.scope == scope) } /// The constraint set for `scope`, creating an empty one on first use. - /// The `pub(crate)` `sketch_constraints` field itself stays private so + /// The `pub(crate)` `parametric_constraints` field itself stays private so /// nothing outside this module can end up with two sets for the same /// scope — this is the one way to reach a scope's set for both reading /// and mutating. - pub fn sketch_constraint_set_mut(&mut self, scope: SketchScope) -> &mut SketchConstraintSet { + pub fn parametric_constraint_set_mut( + &mut self, + scope: ParametricScope, + ) -> &mut ParametricConstraintSet { if let Some(index) = self - .sketch_constraints + .parametric_constraints .iter() .position(|s| s.scope == scope) { - &mut self.sketch_constraints[index] + &mut self.parametric_constraints[index] } else { - self.sketch_constraints - .push(SketchConstraintSet::new(scope)); - self.sketch_constraints.last_mut().expect("just pushed") + self.parametric_constraints + .push(ParametricConstraintSet::new(scope)); + self.parametric_constraints.last_mut().expect("just pushed") } } @@ -654,24 +804,28 @@ impl super::Scene { /// scope, then triggers a solve for the newly duplicated geometry the /// same way any other edit would. A no-op when `handle_map` is empty or /// nothing constrained was duplicated. - pub fn duplicate_sketch_constraints_for( + pub fn duplicate_parametric_constraints_for( &mut self, handle_map: &rustc_hash::FxHashMap, ) { if handle_map.is_empty() { return; } - let mut to_add: Vec<(usize, ConstraintKind, Vec, Option)> = - Vec::new(); - for (scope_index, set) in self.sketch_constraints.iter().enumerate() { + let mut to_add: Vec<( + usize, + ConstraintKind, + Vec, + Option, + )> = Vec::new(); + for (scope_index, set) in self.parametric_constraints.iter().enumerate() { for c in &set.constraints { if !c.enabled || !c.refs.iter().all(|r| handle_map.contains_key(&r.entity)) { continue; } - let new_refs: Vec = c + let new_refs: Vec = c .refs .iter() - .map(|r| SketchRef { + .map(|r| ParametricRef { entity: handle_map[&r.entity], marker: r.marker, }) @@ -685,7 +839,7 @@ impl super::Scene { let mut touched: Vec = Vec::new(); for (scope_index, kind, refs, driving_param) in to_add { touched.extend(refs.iter().map(|r| r.entity)); - self.sketch_constraints[scope_index].add(kind, refs, driving_param); + self.parametric_constraints[scope_index].add(kind, refs, driving_param); } touched.sort(); touched.dedup(); @@ -705,7 +859,7 @@ impl super::Scene { /// still needs `Scene::document.get_entity`. pub fn parameter_usage(&self, name: &str) -> Vec { let mut out = Vec::new(); - for set in &self.sketch_constraints { + for set in &self.parametric_constraints { for c in &set.constraints { let Some(DrivingValue::Named(n)) = &c.driving_param else { continue; @@ -732,7 +886,7 @@ impl super::Scene { /// result type. #[derive(Debug, Clone)] pub struct ParameterUsage { - pub scope: SketchScope, + pub scope: ParametricScope, pub constraint_id: ConstraintId, pub kind: ConstraintKind, pub entities: Vec, @@ -763,38 +917,43 @@ mod tests { .add_entity(acadrust::EntityType::Circle(circle)) .unwrap(); - let constraint = |reference| SketchConstraint { + let constraint = |reference| ParametricConstraint { id: 0, kind: ConstraintKind::Fixed, refs: vec![reference], driving_param: None, enabled: true, + native_origin: None, + rigid_points: Vec::new(), + distance_direction_type: 0, + distance_direction: None, + angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, }; let (anchor, direction) = - glyph_placement(&document, &constraint(SketchRef::whole(h(1)))).unwrap(); + glyph_placement(&document, &constraint(ParametricRef::whole(h(1)))).unwrap(); assert_eq!(anchor, Vector3::new(5.0, 0.0, 0.0)); assert_eq!(direction, Vector3::new(0.0, 10.0, 0.0)); let (anchor, direction) = - glyph_placement(&document, &constraint(SketchRef::point(h(1), 0))).unwrap(); + glyph_placement(&document, &constraint(ParametricRef::point(h(1), 0))).unwrap(); assert_eq!(anchor, Vector3::ZERO); assert_eq!(direction, Vector3::new(-5.0, 0.0, 0.0)); let (anchor, direction) = - glyph_placement(&document, &constraint(SketchRef::center(h(2)))).unwrap(); + glyph_placement(&document, &constraint(ParametricRef::center(h(2)))).unwrap(); assert_eq!(anchor, Vector3::new(5.0, 0.0, 0.0)); assert_eq!(direction, Vector3::new(5.0, 0.0, 0.0)); } #[test] fn add_assigns_increasing_ids_and_get_finds_them() { - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); let a = set.add( ConstraintKind::Horizontal, - vec![SketchRef::whole(h(1))], + vec![ParametricRef::whole(h(1))], None, ); let b = set.add( ConstraintKind::Distance, - vec![SketchRef::whole(h(1))], + vec![ParametricRef::whole(h(1))], Some(DrivingValue::Literal(25.0)), ); assert_ne!(a, b); @@ -807,13 +966,17 @@ mod tests { #[test] fn remove_drops_only_the_matching_id() { - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); let a = set.add( ConstraintKind::Horizontal, - vec![SketchRef::whole(h(1))], + vec![ParametricRef::whole(h(1))], + None, + ); + let b = set.add( + ConstraintKind::Vertical, + vec![ParametricRef::whole(h(2))], None, ); - let b = set.add(ConstraintKind::Vertical, vec![SketchRef::whole(h(2))], None); assert!(set.remove(a)); assert!( !set.remove(a), @@ -825,15 +988,15 @@ mod tests { #[test] fn constraints_touching_finds_entity_regardless_of_marker() { - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); set.add( ConstraintKind::Coincident, - vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], + vec![ParametricRef::point(h(1), 0), ParametricRef::point(h(2), 1)], None, ); set.add( ConstraintKind::Horizontal, - vec![SketchRef::whole(h(3))], + vec![ParametricRef::whole(h(3))], None, ); @@ -846,10 +1009,10 @@ mod tests { #[test] fn constraints_touching_skips_disabled() { - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); let id = set.add( ConstraintKind::Horizontal, - vec![SketchRef::whole(h(1))], + vec![ParametricRef::whole(h(1))], None, ); set.constraints @@ -862,15 +1025,15 @@ mod tests { #[test] fn remove_all_touching_drops_every_constraint_referencing_the_entity() { - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); let coincident = set.add( ConstraintKind::Coincident, - vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], + vec![ParametricRef::point(h(1), 0), ParametricRef::point(h(2), 1)], None, ); let horizontal_other = set.add( ConstraintKind::Horizontal, - vec![SketchRef::whole(h(3))], + vec![ParametricRef::whole(h(3))], None, ); @@ -886,80 +1049,54 @@ mod tests { #[test] fn scope_owner_handle_resolves_block_directly() { let block_handle = h(42); - let scope = SketchScope::Block(block_handle); + let scope = ParametricScope::Block(block_handle); let doc = acadrust::CadDocument::new(); assert_eq!(scope.owner_handle(&doc), block_handle); } #[test] fn ref_center_constructor_matches_the_dash_three_convention() { - let r = SketchRef::center(h(7)); + let r = ParametricRef::center(h(7)); assert_eq!( r, - SketchRef { + ParametricRef { entity: h(7), marker: Some(-3) } ); } - #[test] - fn sketch_constraint_set_round_trips_through_bincode() { - let mut set = SketchConstraintSet::new(SketchScope::Block(h(5))); - set.add( - ConstraintKind::Coincident, - vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], - None, - ); - set.add( - ConstraintKind::Distance, - vec![SketchRef::whole(h(3))], - Some(DrivingValue::Literal(12.5)), - ); - - let bytes = bincode::serialize(&set).expect("serialize"); - let restored: SketchConstraintSet = bincode::deserialize(&bytes).expect("deserialize"); - - assert_eq!(restored.scope, set.scope); - assert_eq!(restored.constraints.len(), set.constraints.len()); - assert_eq!( - restored.constraints[1].driving_param, - Some(DrivingValue::Literal(12.5)) - ); - assert_eq!(restored.constraints[0].refs, set.constraints[0].refs); - } - #[test] fn parameter_usage_finds_every_constraint_driven_by_the_named_parameter() { let mut scene = super::super::Scene::new(); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Distance, - vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], + vec![ParametricRef::point(h(1), 0), ParametricRef::point(h(1), 1)], Some(DrivingValue::Named("gap".to_string())), ); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Radius, - vec![SketchRef::whole(h(2))], + vec![ParametricRef::whole(h(2))], Some(DrivingValue::Named("gap".to_string())), ); // Unrelated: a literal-driven constraint and one driven by a // different name must not show up. scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Radius, - vec![SketchRef::whole(h(3))], + vec![ParametricRef::whole(h(3))], Some(DrivingValue::Literal(5.0)), ); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Distance, - vec![SketchRef::point(h(4), 0), SketchRef::point(h(4), 1)], + vec![ParametricRef::point(h(4), 0), ParametricRef::point(h(4), 1)], Some(DrivingValue::Named("other".to_string())), ); @@ -984,15 +1121,15 @@ mod tests { let mut scene = super::super::Scene::new(); let block = h(99); scene - .sketch_constraint_set_mut(SketchScope::Block(block)) + .parametric_constraint_set_mut(ParametricScope::Block(block)) .add( ConstraintKind::Radius, - vec![SketchRef::whole(h(1))], + vec![ParametricRef::whole(h(1))], Some(DrivingValue::Named("r".to_string())), ); let usage = scene.parameter_usage("r"); assert_eq!(usage.len(), 1); - assert_eq!(usage[0].scope, SketchScope::Block(block)); + assert_eq!(usage[0].scope, ParametricScope::Block(block)); } #[test] @@ -1002,10 +1139,10 @@ mod tests { // entity (e.g. a line's own start and end) must list that entity // once, not twice. scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Distance, - vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], + vec![ParametricRef::point(h(1), 0), ParametricRef::point(h(1), 1)], Some(DrivingValue::Named("len".to_string())), ); let usage = scene.parameter_usage("len"); @@ -1029,26 +1166,28 @@ mod tests { ), )); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Horizontal, - vec![SketchRef::whole(first)], + vec![ParametricRef::whole(first)], None, ); - let inferred = scene.inferred_sketch_constraints( - SketchScope::ModelSpace, - &[first, second], - ); + let inferred = + scene.inferred_parametric_constraints(ParametricScope::ModelSpace, &[first, second]); assert!(!inferred.iter().any(|(kind, refs)| { - *kind == ConstraintKind::Horizontal && *refs == [SketchRef::whole(first)] + *kind == ConstraintKind::Horizontal && *refs == [ParametricRef::whole(first)] })); assert!(inferred.iter().any(|(kind, refs)| { - *kind == ConstraintKind::Horizontal && *refs == [SketchRef::whole(second)] + *kind == ConstraintKind::Horizontal && *refs == [ParametricRef::whole(second)] })); - assert!(inferred.iter().any(|(kind, _)| *kind == ConstraintKind::Coincident)); - assert!(inferred.iter().any(|(kind, _)| *kind == ConstraintKind::Colinear)); + assert!(inferred + .iter() + .any(|(kind, _)| *kind == ConstraintKind::Coincident)); + assert!(inferred + .iter() + .any(|(kind, _)| *kind == ConstraintKind::Colinear)); } #[test] @@ -1060,28 +1199,28 @@ mod tests { Vector3::new(5.0, 0.0, 0.0), ), )); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); let geometric = set.add( ConstraintKind::Horizontal, - vec![SketchRef::whole(line)], + vec![ParametricRef::whole(line)], None, ); let dimensional = set.add( ConstraintKind::Distance, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(DrivingValue::Literal(5.0)), ); assert_eq!( - scene.set_sketch_constraint_visibility( - SketchScope::ModelSpace, + scene.set_parametric_constraint_visibility( + ParametricScope::ModelSpace, None, false, false, ), 1 ); - assert!(!scene.is_sketch_constraint_visible(SketchScope::ModelSpace, geometric)); - assert!(scene.is_sketch_constraint_visible(SketchScope::ModelSpace, dimensional)); + assert!(!scene.is_parametric_constraint_visible(ParametricScope::ModelSpace, geometric)); + assert!(scene.is_parametric_constraint_visible(ParametricScope::ModelSpace, dimensional)); } } diff --git a/src/scene/sketch_solve.rs b/src/scene/parametric_solve.rs similarity index 69% rename from src/scene/sketch_solve.rs rename to src/scene/parametric_solve.rs index 48bb5a530..20e9581bb 100644 --- a/src/scene/sketch_solve.rs +++ b/src/scene/parametric_solve.rs @@ -1,16 +1,15 @@ -//! Builds and solves persistent two-dimensional sketch constraints through the geometry kernel. +//! Builds and solves two-dimensional parametric constraints through the geometry kernel. //! Unsupported entity shapes, invalid target values, and incompatible planes are skipped. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use acadrust::entities::EntityType; use acadrust::types::{Handle, Vector3}; use cadkernel_constraints::constraints::angle_distance::L2LAngle; -use cadkernel_constraints::constraints::circle_arc::{ - ArcLength, C2LDistance, P2CDistance, TangentCircumf, -}; +use cadkernel_constraints::constraints::bspline::{Coord as SplineCoord, PointOnBSpline}; +use cadkernel_constraints::constraints::circle_arc::{C2LDistance, P2CDistance, TangentCircumf}; use cadkernel_constraints::constraints::conic::{ EqualMajorAxesConic, PointOnEllipse, TangentEllipseLine, }; @@ -18,18 +17,20 @@ use cadkernel_constraints::constraints::curve_generic::CurveValue; use cadkernel_constraints::constraints::point_line::{ CenterOfGravity, Difference, Equal, EqualLineLength, MidpointOnLine, P2PDistance, Parallel as ParallelConstraint, Perpendicular as PerpendicularConstraint, PointOnLine, + ProjectedDistance, ProjectedDistanceAlongLine, }; use cadkernel_constraints::constraints::Constraint; use cadkernel_constraints::geo::{ - Arc as GArc, Circle as GCircle, Conic as GConic, Ellipse as GEllipse, Line as GLine, - Point as GPoint, + Arc as GArc, BSpline as GBSpline, Circle as GCircle, Conic as GConic, Ellipse as GEllipse, + Line as GLine, Point as GPoint, }; use cadkernel_constraints::solvers::dogleg::solve_dl; use cadkernel_constraints::system::System; use super::named_parameters::ParameterTable; -use super::sketch_constraints::{ - ConstraintId, ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef, +use super::parametric_constraints::{ + angle_sector, distance_direction_type, ConstraintId, ConstraintKind, ParametricConstraint, + ParametricConstraintSet, ParametricRef, }; use super::{ChangeKind, Scene}; @@ -39,6 +40,8 @@ use super::{ChangeKind, Scene}; enum EntityGeom { Point(GPoint), Line(GLine), + Ray(GLine), + XLine(GLine), Polyline { points: Rc>, straight: Rc>, @@ -52,18 +55,23 @@ enum EntityGeom { Arc(GArc), /// See `register_entity` for the entity-to-kernel parametrization. Ellipse(GEllipse), + Spline { + curve: GBSpline, + control_z: Rc>, + }, } impl EntityGeom { /// The `cadkernel_constraints::geo::Point` for a marker on this entity — `None` for /// a marker this entity type/value doesn't support (see - /// `sketch_constraints::resolve_point` for the same convention on the + /// `parametric_constraints::resolve_point` for the same convention on the /// read side). fn point_for_marker(&self, marker: i32) -> Option { match (self, marker) { (EntityGeom::Point(point), 0) => Some(*point), (EntityGeom::Line(l), 0) => Some(l.p1), (EntityGeom::Line(l), 1) => Some(l.p2), + (EntityGeom::Ray(l), 0) => Some(l.p1), (EntityGeom::Polyline { points, .. }, marker) if marker >= 0 => { points.get(marker as usize).copied() } @@ -72,6 +80,8 @@ impl EntityGeom { (EntityGeom::Arc(a), 1) => Some(a.end), (EntityGeom::Arc(a), -3) => Some(a.circle.center), (EntityGeom::Ellipse(e), -3) => Some(e.center), + (EntityGeom::Spline { curve, .. }, 0) if !curve.periodic => Some(curve.start), + (EntityGeom::Spline { curve, .. }, 1) if !curve.periodic => Some(curve.end), _ => None, } } @@ -111,13 +121,14 @@ enum CircleOrLine { Other, } -fn as_circle_or_line(g: EntityGeom, reference: SketchRef) -> CircleOrLine { +fn as_circle_or_line(g: EntityGeom, reference: ParametricRef) -> CircleOrLine { match g { EntityGeom::Circle(c) => CircleOrLine::Circle(c), EntityGeom::Arc(a) => CircleOrLine::Circle(a.circle), - EntityGeom::Line(l) => CircleOrLine::Line(l), + EntityGeom::Line(l) | EntityGeom::Ray(l) | EntityGeom::XLine(l) => CircleOrLine::Line(l), EntityGeom::Ellipse(ellipse) => CircleOrLine::Ellipse(ellipse), EntityGeom::Point(_) => CircleOrLine::Other, + EntityGeom::Spline { .. } => CircleOrLine::Other, EntityGeom::Polyline { .. } => reference .segment_index() .and_then(|index| g.line_segment(index)) @@ -215,6 +226,30 @@ fn register_entity( let p2 = GPoint::new(sys.add_param(l.end.x, false), sys.add_param(l.end.y, false)); Some(EntityGeom::Line(GLine { p1, p2 })) } + EntityType::Ray(ray) => { + let p1 = GPoint::new( + sys.add_param(ray.base_point.x, false), + sys.add_param(ray.base_point.y, false), + ); + let through = ray.base_point + ray.direction; + let p2 = GPoint::new( + sys.add_param(through.x, false), + sys.add_param(through.y, false), + ); + Some(EntityGeom::Ray(GLine { p1, p2 })) + } + EntityType::XLine(line) => { + let p1 = GPoint::new( + sys.add_param(line.base_point.x, false), + sys.add_param(line.base_point.y, false), + ); + let through = line.base_point + line.direction; + let p2 = GPoint::new( + sys.add_param(through.x, false), + sys.add_param(through.y, false), + ); + Some(EntityGeom::XLine(GLine { p1, p2 })) + } EntityType::Circle(c) => { let center = GPoint::new( sys.add_param(c.center.x, false), @@ -291,11 +326,49 @@ fn register_entity( radmin, })) } + EntityType::Spline(spline) => { + let source = crate::entities::spline::nurbs3(spline)?; + let poles: Vec<_> = source + .control_points() + .iter() + .map(|point| { + GPoint::new( + sys.add_param(point[0], false), + sys.add_param(point[1], false), + ) + }) + .collect(); + let start = *poles.first()?; + let end = *poles.last()?; + let weights = source + .weights() + .iter() + .map(|weight| sys.add_param(*weight, true)) + .collect(); + Some(EntityGeom::Spline { + curve: GBSpline { + poles, + weights, + knots: source.knots().to_vec(), + start, + end, + degree: source.degree(), + periodic: source.periodicity(), + }, + control_z: Rc::new( + source + .control_points() + .iter() + .map(|point| point[2]) + .collect(), + ), + }) + } _ => None, } } -/// Resolves one [`SketchRef`] against the entity-geometry cache, registering +/// Resolves one [`ParametricRef`] against the entity-geometry cache, registering /// the entity on first use. `None` if the handle is dangling, isn't a /// supported entity type, or (for a point ref) uses a marker that entity /// type doesn't support. @@ -303,7 +376,7 @@ fn resolve_ref( document: &acadrust::CadDocument, sys: &mut System, cache: &mut HashMap, - r: SketchRef, + r: ParametricRef, ) -> Option { if let Some(geometry) = cache.get(&r.entity) { return Some(geometry.clone()); @@ -313,6 +386,166 @@ fn resolve_ref( Some(geometry) } +/// Resolves a point reference and installs the kernel equations for standard +/// implicit midpoints. Endpoint and center markers reuse entity parameters; +/// midpoint markers are derived parameters so moving either parent geometry +/// or another constrained object recomputes them in the same solve. +fn resolve_constraint_point( + document: &acadrust::CadDocument, + sys: &mut System, + cache: &mut HashMap, + reference: ParametricRef, +) -> Option { + let marker = reference.marker?; + let geometry = resolve_ref(document, sys, cache, reference)?; + if let Some(point) = geometry.point_for_marker(marker) { + return Some(point); + } + + let midpoint_line = reference + .segment_midpoint_index() + .and_then(|index| geometry.line_segment(index)) + .or_else(|| { + (marker == -2) + .then_some(&geometry) + .and_then(|geometry| match geometry { + EntityGeom::Line(line) => Some(*line), + _ => None, + }) + }); + if let Some(line) = midpoint_line { + let (x1, y1, x2, y2) = { + let store = sys.store(); + ( + store.get(line.p1.x), + store.get(line.p1.y), + store.get(line.p2.x), + store.get(line.p2.y), + ) + }; + let point = GPoint::new( + sys.add_param((x1 + x2) * 0.5, false), + sys.add_param((y1 + y2) * 0.5, false), + ); + sys.add_constraint(Rc::new(CenterOfGravity::new( + point.x, + vec![line.p1.x, line.p2.x], + vec![0.5, 0.5], + ))); + sys.add_constraint(Rc::new(CenterOfGravity::new( + point.y, + vec![line.p1.y, line.p2.y], + vec![0.5, 0.5], + ))); + return Some(point); + } + if marker != -2 { + return None; + } + + match geometry { + EntityGeom::Arc(arc) => { + let (start, end) = { + let store = sys.store(); + (store.get(arc.start_angle), store.get(arc.end_angle)) + }; + let parameter_value = if end < start { + (start + end + std::f64::consts::TAU) * 0.5 + } else { + (start + end) * 0.5 + }; + let end_parameter = if end < start { + let adjusted = sys.add_param(end + std::f64::consts::TAU, false); + let turn = sys.add_param(std::f64::consts::TAU, true); + sys.add_constraint(Rc::new(Difference::new(arc.end_angle, adjusted, turn))); + adjusted + } else { + arc.end_angle + }; + let parameter = sys.add_param(parameter_value, false); + sys.add_constraint(Rc::new(CenterOfGravity::new( + parameter, + vec![arc.start_angle, end_parameter], + vec![0.5, 0.5], + ))); + let point = { + let value = cadkernel_constraints::geo::Curve::value( + &arc, + sys.store(), + parameter_value, + 0.0, + None, + ); + GPoint::new(sys.add_param(value.x, false), sys.add_param(value.y, false)) + }; + let curve = Rc::new(arc); + sys.add_constraint(Rc::new(CurveValue::new( + point, + point.x, + curve.clone(), + parameter, + ))); + sys.add_constraint(Rc::new(CurveValue::new(point, point.y, curve, parameter))); + Some(point) + } + EntityGeom::Ellipse(ellipse) => { + let EntityType::Ellipse(source) = document.get_entity(reference.entity)? else { + return None; + }; + if source.is_full() { + return None; + } + let parameter_value = (source.start_parameter + source.end_parameter) * 0.5; + let value = cadkernel_constraints::geo::Curve::value( + &ellipse, + sys.store(), + parameter_value, + 0.0, + None, + ); + let point = GPoint::new(sys.add_param(value.x, false), sys.add_param(value.y, false)); + let parameter = sys.add_param(parameter_value, true); + let curve = Rc::new(ellipse); + sys.add_constraint(Rc::new(CurveValue::new( + point, + point.x, + curve.clone(), + parameter, + ))); + sys.add_constraint(Rc::new(CurveValue::new(point, point.y, curve, parameter))); + Some(point) + } + EntityGeom::Spline { curve, .. } if !curve.periodic => { + let from = curve.knots[curve.degree]; + let to = curve.knots[curve.poles.len()]; + let parameter_value = (from + to) * 0.5; + let value = cadkernel_constraints::geo::Curve::value( + &curve, + sys.store(), + parameter_value, + 0.0, + None, + ); + let point = GPoint::new(sys.add_param(value.x, false), sys.add_param(value.y, false)); + let parameter = sys.add_param(parameter_value, true); + sys.add_constraint(Rc::new(PointOnBSpline::new( + point.x, + parameter, + SplineCoord::X, + curve.clone(), + ))); + sys.add_constraint(Rc::new(PointOnBSpline::new( + point.y, + parameter, + SplineCoord::Y, + curve, + ))); + Some(point) + } + _ => None, + } +} + const PLANE_EPS: f64 = 1e-9; fn is_world_z(normal: acadrust::types::Vector3) -> bool { @@ -340,6 +573,16 @@ fn entity_plane_z(entity: &EntityType) -> Option { { line.start.z } + EntityType::Ray(ray) + if ray.direction.z.abs() <= PLANE_EPS && ray.base_point.z.is_finite() => + { + ray.base_point.z + } + EntityType::XLine(line) + if line.direction.z.abs() <= PLANE_EPS && line.base_point.z.is_finite() => + { + line.base_point.z + } EntityType::Circle(circle) if is_world_z(circle.normal) => circle.center.z, EntityType::Arc(arc) if is_world_z(arc.normal) => arc.center.z, EntityType::Ellipse(ellipse) @@ -347,7 +590,7 @@ fn entity_plane_z(entity: &EntityType) -> Option { { ellipse.center.z } - EntityType::Spline(spline) if !spline.flags.closed && !spline.flags.periodic => { + EntityType::Spline(spline) => { let curve = crate::entities::spline::nurbs3(spline)?; let first = *curve.control_points().first()?; if !curve @@ -386,7 +629,7 @@ fn entity_plane_z(entity: &EntityType) -> Option { z.is_finite().then_some(z) } -fn refs_share_supported_plane(document: &acadrust::CadDocument, refs: &[SketchRef]) -> bool { +fn refs_share_supported_plane(document: &acadrust::CadDocument, refs: &[ParametricRef]) -> bool { let mut plane_z = None; for reference in refs { let Some(entity) = document.get_entity(reference.entity) else { @@ -403,22 +646,27 @@ fn refs_share_supported_plane(document: &acadrust::CadDocument, refs: &[SketchRe plane_z.is_some() } -fn resolved_target(params: &ParameterTable, constraint: &SketchConstraint) -> Option { +fn resolved_target(params: &ParameterTable, constraint: &ParametricConstraint) -> Option { let value = constraint.driving_param.as_ref()?.resolve(params).ok()?; if !value.is_finite() { return None; } let must_be_positive = matches!( constraint.kind, - ConstraintKind::Distance - | ConstraintKind::Radius - | ConstraintKind::Diameter - | ConstraintKind::ArcLength + ConstraintKind::Distance | ConstraintKind::Radius | ConstraintKind::Diameter ); (!must_be_positive || value > 0.0).then_some(value) } -/// One [`SketchConstraint`]'s `cadkernel_constraints` construction: every system-level +fn oriented_distance(target: f64, current_projection: f64) -> f64 { + if target >= 0.0 && current_projection < 0.0 { + -target + } else { + target + } +} + +/// One [`ParametricConstraint`]'s `cadkernel_constraints` construction: every system-level /// constraint it contributes (most kinds contribute exactly one; a few — /// `Coincident`/`Concentric`/`CenterPoint`'s X+Y halves, `Colinear`'s two /// endpoints, `Midpoint`'s X+Y, `Symmetric`'s midpoint+perpendicular pair, @@ -438,13 +686,21 @@ fn build_constraint( sys: &mut System, cache: &mut HashMap, params: &ParameterTable, - c: &SketchConstraint, + c: &ParametricConstraint, ) -> Vec> { if !c.enabled || !refs_share_supported_plane(document, &c.refs) { return Vec::new(); } - let whole_line = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| { + let whole_line = |sys: &mut System, cache: &mut HashMap<_, _>, r: ParametricRef| { + let geometry = resolve_ref(document, sys, cache, r)?; + match geometry { + EntityGeom::Line(line) | EntityGeom::Ray(line) | EntityGeom::XLine(line) => Some(line), + EntityGeom::Polyline { .. } => geometry.line_segment(r.segment_index()?), + _ => None, + } + }; + let bounded_line = |sys: &mut System, cache: &mut HashMap<_, _>, r: ParametricRef| { let geometry = resolve_ref(document, sys, cache, r)?; match geometry { EntityGeom::Line(line) => Some(line), @@ -453,19 +709,21 @@ fn build_constraint( } }; let whole_circle = - |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref( + |sys: &mut System, cache: &mut HashMap<_, _>, r: ParametricRef| match resolve_ref( document, sys, cache, r, )? { EntityGeom::Circle(circ) => Some(circ), EntityGeom::Arc(a) => Some(a.circle), EntityGeom::Point(_) | EntityGeom::Line(_) + | EntityGeom::Ray(_) + | EntityGeom::XLine(_) | EntityGeom::Polyline { .. } - | EntityGeom::Ellipse(_) => None, + | EntityGeom::Ellipse(_) + | EntityGeom::Spline { .. } => None, }; - let point_ref = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| { - let marker = r.marker?; - resolve_ref(document, sys, cache, r)?.point_for_marker(marker) + let point_ref = |sys: &mut System, cache: &mut HashMap<_, _>, r: ParametricRef| { + resolve_constraint_point(document, sys, cache, r) }; // `Coincident`/`Concentric`/`CenterPoint` all solve identically — two @@ -474,7 +732,7 @@ fn build_constraint( // name and the UI entry point that produces their `refs` differ. let point_pair_equal = |sys: &mut System, cache: &mut HashMap<_, _>, - refs: &[SketchRef]| + refs: &[ParametricRef]| -> Vec> { let [a, b] = refs else { return Vec::new() }; let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) else { @@ -544,7 +802,9 @@ fn build_constraint( let [a, b] = c.refs.as_slice() else { return Vec::new(); }; - if let (Some(la), Some(lb)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) { + if let (Some(la), Some(lb)) = + (bounded_line(sys, cache, *a), bounded_line(sys, cache, *b)) + { return vec![Rc::new(EqualLineLength::new(lb, la))]; } if let (Some(EntityGeom::Ellipse(a)), Some(EntityGeom::Ellipse(b))) = ( @@ -601,7 +861,8 @@ fn build_constraint( let [a, b] = c.refs.as_slice() else { return Vec::new(); }; - let (Some(p), Some(l)) = (point_ref(sys, cache, *a), whole_line(sys, cache, *b)) else { + let (Some(p), Some(l)) = (point_ref(sys, cache, *a), bounded_line(sys, cache, *b)) + else { return Vec::new(); }; vec![ @@ -628,7 +889,9 @@ fn build_constraint( return Vec::new(); }; match geom { - EntityGeom::Line(l) => vec![Rc::new(PointOnLine::new(p, l))], + EntityGeom::Line(l) | EntityGeom::Ray(l) | EntityGeom::XLine(l) => { + vec![Rc::new(PointOnLine::new(p, l))] + } EntityGeom::Circle(circ) => { let zero = sys.add_param(0.0, true); vec![Rc::new(P2CDistance::new(circ, p, zero))] @@ -644,6 +907,32 @@ fn build_constraint( EntityGeom::Ellipse(ellipse) => { vec![Rc::new(PointOnEllipse::new(p, ellipse))] } + EntityGeom::Spline { curve, .. } => { + let (x, y) = { + let store = sys.store(); + (store.get(p.x), store.get(p.y)) + }; + let Some(EntityType::Spline(source)) = document.get_entity(b.entity) else { + return Vec::new(); + }; + let Some(source_curve) = crate::entities::spline::nurbs3(source) else { + return Vec::new(); + }; + let Some(z) = source_curve.control_points().first().map(|point| point[2]) + else { + return Vec::new(); + }; + let parameter = sys.add_param(source_curve.parameter_at([x, y, z]), false); + vec![ + Rc::new(PointOnBSpline::new( + p.x, + parameter, + SplineCoord::X, + curve.clone(), + )), + Rc::new(PointOnBSpline::new(p.y, parameter, SplineCoord::Y, curve)), + ] + } EntityGeom::Polyline { .. } => b .segment_index() .and_then(|index| geom.line_segment(index)) @@ -740,7 +1029,7 @@ fn build_constraint( }) .collect() } - EntityGeom::Line(l) => { + EntityGeom::Line(l) | EntityGeom::Ray(l) | EntityGeom::XLine(l) => { let (x1, y1, x2, y2) = { let store = sys.store(); ( @@ -817,6 +1106,27 @@ fn build_constraint( Rc::new(Equal::new(el.radmin, sys.add_param(b, true), 1.0)), ] } + EntityGeom::Spline { curve, .. } => { + let values: Vec<_> = curve + .poles + .iter() + .map(|point| { + let store = sys.store(); + (point, store.get(point.x), store.get(point.y)) + }) + .collect(); + values + .into_iter() + .flat_map(|(point, x, y)| { + [ + Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)) + as Rc, + Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)) + as Rc, + ] + }) + .collect() + } } } ConstraintKind::Distance => { @@ -833,6 +1143,77 @@ fn build_constraint( let target = sys.add_param(resolved, true); vec![Rc::new(P2PDistance::new(pa, pb, target))] } + ConstraintKind::DistanceDirected => { + let Some((&a, rest)) = c.refs.split_first() else { + return Vec::new(); + }; + let Some((&b, direction_ref)) = rest.split_first() else { + return Vec::new(); + }; + let (Some(pa), Some(pb)) = (point_ref(sys, cache, a), point_ref(sys, cache, b)) else { + return Vec::new(); + }; + let Some(resolved) = resolved_target(params, c) else { + return Vec::new(); + }; + if matches!( + c.distance_direction_type, + distance_direction_type::PARALLEL_TO_LINE + | distance_direction_type::PERPENDICULAR_TO_LINE + ) { + if let Some(line) = direction_ref + .first() + .and_then(|reference| whole_line(sys, cache, *reference)) + { + let current = { + let store = sys.store(); + let delta = [ + store.get(pb.x) - store.get(pa.x), + store.get(pb.y) - store.get(pa.y), + ]; + let axis = [ + store.get(line.p2.x) - store.get(line.p1.x), + store.get(line.p2.y) - store.get(line.p1.y), + ]; + let length = axis[0].hypot(axis[1]); + if length <= f64::EPSILON { + 0.0 + } else if c.distance_direction_type + == distance_direction_type::PERPENDICULAR_TO_LINE + { + delta[0] * (-axis[1] / length) + delta[1] * (axis[0] / length) + } else { + delta[0] * (axis[0] / length) + delta[1] * (axis[1] / length) + } + }; + let target = sys.add_param(oriented_distance(resolved, current), true); + return vec![Rc::new(ProjectedDistanceAlongLine::new( + pa, + pb, + target, + line, + c.distance_direction_type == distance_direction_type::PERPENDICULAR_TO_LINE, + ))]; + } + } + let Some(direction) = c.distance_direction else { + return Vec::new(); + }; + let current = { + let store = sys.store(); + let length = direction.x.hypot(direction.y); + if length <= f64::EPSILON { + 0.0 + } else { + (store.get(pb.x) - store.get(pa.x)) * direction.x / length + + (store.get(pb.y) - store.get(pa.y)) * direction.y / length + } + }; + let target = sys.add_param(oriented_distance(resolved, current), true); + ProjectedDistance::new(pa, pb, target, [direction.x, direction.y]) + .map(|constraint| vec![Rc::new(constraint) as Rc]) + .unwrap_or_default() + } ConstraintKind::Angle => { let [a, b] = c.refs.as_slice() else { return Vec::new(); @@ -845,9 +1226,44 @@ fn build_constraint( let Some(resolved) = resolved_target(params, c) else { return Vec::new(); }; - let angle = sys.add_param(resolved.to_radians(), true); + let angle = resolved.to_radians(); + let angle = match c.angle_sector { + angle_sector::ANTIPARALLEL_CLOCKWISE => std::f64::consts::PI - angle, + angle_sector::PARALLEL_CLOCKWISE => -angle, + angle_sector::ANTIPARALLEL_COUNTERCLOCKWISE => std::f64::consts::PI + angle, + _ => angle, + }; + let angle = sys.add_param(angle, true); vec![Rc::new(L2LAngle::new(fixed, moving, angle))] } + ConstraintKind::Angle3Point => { + let [a, vertex, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(a), Some(vertex), Some(b)) = ( + point_ref(sys, cache, *a), + point_ref(sys, cache, *vertex), + point_ref(sys, cache, *b), + ) else { + return Vec::new(); + }; + let Some(resolved) = resolved_target(params, c) else { + return Vec::new(); + }; + let angle = resolved.to_radians(); + let angle = match c.angle_sector { + angle_sector::ANTIPARALLEL_CLOCKWISE => std::f64::consts::PI - angle, + angle_sector::PARALLEL_CLOCKWISE => -angle, + angle_sector::ANTIPARALLEL_COUNTERCLOCKWISE => std::f64::consts::PI + angle, + _ => angle, + }; + let angle = sys.add_param(angle, true); + vec![Rc::new(L2LAngle::new( + GLine { p1: vertex, p2: a }, + GLine { p1: vertex, p2: b }, + angle, + ))] + } ConstraintKind::Radius => { let Some(r) = c.refs.first() else { return Vec::new(); @@ -879,11 +1295,9 @@ fn build_constraint( let target = sys.add_param(resolved, true); vec![Rc::new(Equal::new(circle.rad, target, 0.5))] } - // Signed X-only/Y-only component of the distance between two - // points — `Difference::new(p1, p2, d)` is `p2 - p1 == d`, matching - // the native format's `ACDISTANCECONSTRAINT` with a fixed-direction - // vector rather than a distinct native class - // (`dwg_native_constraints.rs`). + // X-only/Y-only distance between two points. Positive standard + // dimension values are magnitudes, so retain the geometry's current + // orientation; an explicitly negative command value remains signed. ConstraintKind::DistanceX => { let [a, b] = c.refs.as_slice() else { return Vec::new(); @@ -895,7 +1309,11 @@ fn build_constraint( let Some(resolved) = resolved_target(params, c) else { return Vec::new(); }; - let target = sys.add_param(resolved, true); + let current = { + let store = sys.store(); + store.get(pb.x) - store.get(pa.x) + }; + let target = sys.add_param(oriented_distance(resolved, current), true); vec![Rc::new(Difference::new(pa.x, pb.x, target))] } ConstraintKind::DistanceY => { @@ -909,7 +1327,11 @@ fn build_constraint( let Some(resolved) = resolved_target(params, c) else { return Vec::new(); }; - let target = sys.add_param(resolved, true); + let current = { + let store = sys.store(); + store.get(pb.y) - store.get(pa.y) + }; + let target = sys.add_param(oriented_distance(resolved, current), true); vec![Rc::new(Difference::new(pa.y, pb.y, target))] } ConstraintKind::Tangent => { @@ -977,6 +1399,39 @@ fn build_constraint( // Smooth edits the endpoint controls through cadkernel's spatial // NURBS operation after the ordinary 2D system has settled. ConstraintKind::Smooth => Vec::new(), + ConstraintKind::RigidSet => { + let points: Vec<_> = c + .rigid_points + .iter() + .filter_map(|(reference, original)| { + point_ref(sys, cache, *reference).map(|point| (point, *original)) + }) + .collect(); + let Some(second) = (1..points.len()).find(|&index| { + let delta = points[index].1 - points[0].1; + delta.x * delta.x + delta.y * delta.y > 1.0e-18 + }) else { + return Vec::new(); + }; + let mut pairs = vec![(0, second)]; + for index in 1..points.len() { + if index == second { + continue; + } + pairs.push((0, index)); + pairs.push((second, index)); + } + pairs + .into_iter() + .map(|(a, b)| { + let delta = points[b].1 - points[a].1; + let target = + sys.add_param((delta.x * delta.x + delta.y * delta.y).sqrt(), true); + Rc::new(P2PDistance::new(points[a].0, points[b].0, target)) + as Rc + }) + .collect() + } // A circle's radius is always normal to its own tangent, so // "line normal to circle/arc" reduces to "line passes through the // circle's center" — the same `PointOnLine` primitive @@ -1002,19 +1457,6 @@ fn build_constraint( _ => Vec::new(), } } - ConstraintKind::ArcLength => { - let Some(r) = c.refs.first() else { - return Vec::new(); - }; - let Some(EntityGeom::Arc(arc)) = resolve_ref(document, sys, cache, *r) else { - return Vec::new(); - }; - let Some(resolved) = resolved_target(params, c) else { - return Vec::new(); - }; - let target = sys.add_param(resolved, true); - vec![Rc::new(ArcLength::new(arc, target))] - } } } @@ -1042,7 +1484,7 @@ fn smooth_target_jet(entity: &EntityType, marker: i32) -> Option, ) { for constraint in set @@ -1120,16 +1562,21 @@ type SolveResult = ( /// on a successful solve. fn solve_scope( document: &acadrust::CadDocument, - params: &ParameterTable, - set: &SketchConstraintSet, + drawing_params: &ParameterTable, + set: &ParametricConstraintSet, ) -> Option { + let params = if set.local_parameters.is_empty() { + drawing_params + } else { + &set.local_parameters + }; let mut sys = System::new(); let mut cache: HashMap = HashMap::new(); // Tracks which system-level `cadkernel_constraints` constraint(s) came from which - // `SketchConstraint` — a `SubSystem`'s redundant-row indices (from + // `ParametricConstraint` — a `SubSystem`'s redundant-row indices (from // `cadkernel_constraints::diagnosis`) are local to that partition's own constraint // list, not `set.constraints`' indices, and several kinds contribute - // more than one system-level constraint per `SketchConstraint` (see + // more than one system-level constraint per `ParametricConstraint` (see // `build_constraint`'s doc comment). `Rc::ptr_eq` against this after // partitioning resolves a row back to the `ConstraintId` the UI // actually names. @@ -1153,7 +1600,7 @@ fn solve_scope( // Arc rules (this module's doc comment): keep every registered arc's // `start`/`end` points consistent with its center/radius/angle, // unconditionally — the kernel adds these the moment an arc - // exists, not only when some `SketchConstraint` happens to reference + // exists, not only when some `ParametricConstraint` happens to reference // its endpoint. Not tracked in `owner`: these are solver-internal // bookkeeping, never redundant with anything a user-facing constraint // could name, so there's no `ConstraintId` for them to report against. @@ -1235,7 +1682,7 @@ fn solve_scope( .values() .map(|g| match g { EntityGeom::Point(_) => 2, - EntityGeom::Line(_) => 4, + EntityGeom::Line(_) | EntityGeom::Ray(_) | EntityGeom::XLine(_) => 4, EntityGeom::Polyline { points, .. } => points.len() * 2, EntityGeom::Circle(_) => 3, // Raw param count (center×2, rad, start×2, end×2, both @@ -1247,6 +1694,7 @@ fn solve_scope( // same way any other constraint would. EntityGeom::Arc(_) => 9, EntityGeom::Ellipse(_) => 5, + EntityGeom::Spline { curve, .. } => curve.poles.len() * 2, }) .sum(); let touched_free: usize = partitions.iter().map(|s| s.p_size()).sum(); @@ -1261,12 +1709,20 @@ fn solve_scope( if diag.redundant.is_empty() { continue; } - // Opt-in per `classify_redundant`'s own doc comment (one extra solve - // per redundant row) — only reached when a partition is actually - // over-constrained, which is rare, so this never costs anything on - // the common "no redundancy" path. + let mut seen = HashSet::new(); + let reportable: Vec<_> = diag + .redundant + .iter() + .copied() + .filter(|row| { + owner + .iter() + .find(|(rc, _)| Rc::ptr_eq(rc, &sub.constraints()[*row])) + .is_some_and(|(_, id)| seen.insert(*id)) + }) + .collect(); for (row, kind) in - cadkernel_constraints::diagnosis::classify_redundant(sub, sys.store(), &diag.redundant) + cadkernel_constraints::diagnosis::classify_redundant(sub, sys.store(), &reportable) { let row_constraint = &sub.constraints()[row]; if let Some(&(_, id)) = owner.iter().find(|(rc, _)| Rc::ptr_eq(rc, row_constraint)) { @@ -1394,6 +1850,54 @@ fn solve_scope( results.push((handle, EntityType::Line(updated))); } } + (EntityType::Ray(ray), EntityGeom::Ray(g)) => { + let (x1, y1, x2, y2) = ( + store.get(g.p1.x), + store.get(g.p1.y), + store.get(g.p2.x), + store.get(g.p2.y), + ); + let length = (x2 - x1).hypot(y2 - y1); + let direction = if length > MOVE_EPS { + Vector3::new((x2 - x1) / length, (y2 - y1) / length, 0.0) + } else { + ray.direction + }; + if (x1 - ray.base_point.x).abs() > MOVE_EPS + || (y1 - ray.base_point.y).abs() > MOVE_EPS + || (direction - ray.direction).length() > MOVE_EPS + { + let mut updated = ray.clone(); + updated.base_point.x = x1; + updated.base_point.y = y1; + updated.direction = direction; + results.push((handle, EntityType::Ray(updated))); + } + } + (EntityType::XLine(line), EntityGeom::XLine(g)) => { + let (x1, y1, x2, y2) = ( + store.get(g.p1.x), + store.get(g.p1.y), + store.get(g.p2.x), + store.get(g.p2.y), + ); + let length = (x2 - x1).hypot(y2 - y1); + let direction = if length > MOVE_EPS { + Vector3::new((x2 - x1) / length, (y2 - y1) / length, 0.0) + } else { + line.direction + }; + if (x1 - line.base_point.x).abs() > MOVE_EPS + || (y1 - line.base_point.y).abs() > MOVE_EPS + || (direction - line.direction).length() > MOVE_EPS + { + let mut updated = line.clone(); + updated.base_point.x = x1; + updated.base_point.y = y1; + updated.direction = direction; + results.push((handle, EntityType::XLine(updated))); + } + } (EntityType::LwPolyline(polyline), EntityGeom::Polyline { points, .. }) => { let mut updated = polyline.clone(); let mut changed = false; @@ -1515,6 +2019,41 @@ fn solve_scope( results.push((handle, EntityType::Ellipse(updated))); } } + (EntityType::Spline(spline), EntityGeom::Spline { curve, control_z }) => { + let mut changed = + spline.fit_points.len() > 0 || spline.control_points.len() != curve.poles.len(); + let control_points: Vec<_> = curve + .poles + .iter() + .zip(control_z.iter()) + .enumerate() + .map(|(index, (point, z))| { + let next = Vector3::new(store.get(point.x), store.get(point.y), *z); + changed |= spline + .control_points + .get(index) + .is_none_or(|old| (next - *old).length() > MOVE_EPS); + next + }) + .collect(); + if changed { + let mut updated = spline.clone(); + updated.degree = curve.degree as i32; + updated.knots = curve.knots.clone(); + updated.control_points = control_points; + updated.weights = curve.weights.iter().map(|id| store.get(*id)).collect(); + updated.fit_points.clear(); + updated.begin_tangent = Vector3::ZERO; + updated.end_tangent = Vector3::ZERO; + updated.flags.rational = updated + .weights + .windows(2) + .any(|pair| (pair[0] - pair[1]).abs() > 1.0e-12); + updated.flags.closed = curve.periodic; + updated.flags.periodic = curve.periodic; + results.push((handle, EntityType::Spline(updated))); + } + } _ => {} } } @@ -1525,10 +2064,10 @@ fn solve_scope( impl Scene { /// Rejects constraints the 2D kernel bridge cannot represent before they /// are persisted or reported as applied. - pub(crate) fn validate_sketch_constraint( + pub(crate) fn validate_parametric_constraint( &self, kind: ConstraintKind, - refs: &[SketchRef], + refs: &[ParametricRef], driving_param: Option<&super::named_parameters::DrivingValue>, ) -> Result<(), &'static str> { if !refs_share_supported_plane(&self.document, refs) { @@ -1580,12 +2119,17 @@ impl Scene { } value => value.cloned(), }; - let candidate = SketchConstraint { + let candidate = ParametricConstraint { id: 0, kind, refs: refs.to_vec(), driving_param: validation_target, enabled: true, + native_origin: None, + rigid_points: Vec::new(), + distance_direction_type: 0, + distance_direction: None, + angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, }; let mut system = System::new(); let mut cache = HashMap::new(); @@ -1607,7 +2151,7 @@ impl Scene { /// the resulting `(Handle, ChangeKind::Modified)` entries the same way /// `refresh_associative_dimensions`/`_hatches` do, for `bump_entities` /// to fold into its own `changes` vec. - pub(crate) fn refresh_sketch_constraints( + pub(crate) fn refresh_parametric_constraints( &mut self, changes: &[(Handle, ChangeKind)], ) -> Vec<(Handle, ChangeKind)> { @@ -1619,7 +2163,7 @@ impl Scene { // // Recorded into the *same* undo transaction as the entity removal // that caused it (audit finding: ERASE undo silently lost constraint - // state, since `sketch_constraints` lives outside `document`/ + // state, since `parametric_constraints` lives outside `document`/ // `document.objects` and neither of those directories ever saw this // mutation) — one undo press now restores both the entity and its // constraints together. @@ -1627,24 +2171,24 @@ impl Scene { if *kind != ChangeKind::Removed { continue; } - for i in 0..self.sketch_constraints.len() { - if self.sketch_constraints[i] - .constraints_touching(*handle) - .next() - .is_some() + for i in 0..self.parametric_constraints.len() { + if self.parametric_constraints[i] + .constraints + .iter() + .any(|constraint| constraint.refs.iter().any(|r| r.entity == *handle)) { - let scope = self.sketch_constraints[i].scope; - let before = self.sketch_constraints[i].clone(); - self.record_undo_sketch_constraints_before(scope, before); - self.sketch_constraints[i].remove_all_touching(*handle); + let scope = self.parametric_constraints[i].scope; + let before = self.parametric_constraints[i].clone(); + self.record_undo_parametric_constraints_before(scope, before); + self.parametric_constraints[i].remove_all_touching(*handle); } } } let mut result = Vec::new(); - for i in 0..self.sketch_constraints.len() { + for i in 0..self.parametric_constraints.len() { let touched = changes.iter().any(|(handle, _)| { - self.sketch_constraints[i] + self.parametric_constraints[i] .constraints_touching(*handle) .next() .is_some() @@ -1655,12 +2199,12 @@ impl Scene { let Some((solved, dof, conflicts)) = solve_scope( &self.document, &self.named_parameters, - &self.sketch_constraints[i], + &self.parametric_constraints[i], ) else { continue; }; - self.sketch_constraints[i].dof = Some(dof); - self.sketch_constraints[i].conflicts = conflicts; + self.parametric_constraints[i].dof = Some(dof); + self.parametric_constraints[i].conflicts = conflicts; for (handle, new_entity) in solved { if let Some(before) = self.document.get_entity_arc(handle) { self.record_undo_before(handle, Some(before)); @@ -1677,15 +2221,15 @@ impl Scene { /// Re-solves touched scopes during a grip drag without mutating the /// document or recording undo. The caller owns preview write-back and the /// final gesture history entry. - pub(crate) fn solve_sketch_constraints_preview( + pub(crate) fn solve_parametric_constraints_preview( &self, touched: &[Handle], ) -> Vec<(Handle, EntityType)> { - if self.sketch_constraints.is_empty() || touched.is_empty() { + if self.parametric_constraints.is_empty() || touched.is_empty() { return Vec::new(); } let mut result = Vec::new(); - for set in &self.sketch_constraints { + for set in &self.parametric_constraints { let is_touched = touched .iter() .any(|handle| set.constraints_touching(*handle).next().is_some()); @@ -1706,8 +2250,8 @@ impl Scene { #[cfg(test)] mod tests { use super::super::named_parameters::DrivingValue; - use super::super::sketch_constraints::{ - ConstraintKind, SketchConstraintSet, SketchRef, SketchScope, + use super::super::parametric_constraints::{ + ConstraintKind, ParametricConstraintSet, ParametricRef, ParametricScope, }; use super::Scene; use acadrust::entities::EntityType; @@ -1720,9 +2264,9 @@ mod tests { acadrust::entities::Circle::from_center_radius(Vector3::ZERO, 2.0), )); assert!(scene - .validate_sketch_constraint( + .validate_parametric_constraint( ConstraintKind::Horizontal, - &[SketchRef::whole(circle)], + &[ParametricRef::whole(circle)], None ) .is_err()); @@ -1736,9 +2280,9 @@ mod tests { Vector3::new(1.0, 0.0, 1.0), ))); assert!(scene - .validate_sketch_constraint( + .validate_parametric_constraint( ConstraintKind::Parallel, - &[SketchRef::whole(lower), SketchRef::whole(upper)], + &[ParametricRef::whole(lower), ParametricRef::whole(upper)], None, ) .is_err()); @@ -1751,23 +2295,23 @@ mod tests { Vector3::new(0.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0), ))); - let refs = [SketchRef::point(line, 0), SketchRef::point(line, 1)]; + let refs = [ParametricRef::point(line, 0), ParametricRef::point(line, 1)]; assert!(scene - .validate_sketch_constraint( + .validate_parametric_constraint( ConstraintKind::Distance, &refs, Some(&DrivingValue::Literal(-1.0)), ) .is_err()); assert!(scene - .validate_sketch_constraint( + .validate_parametric_constraint( ConstraintKind::DistanceX, &refs, Some(&DrivingValue::Literal(-1.0)), ) .is_ok()); assert!(scene - .validate_sketch_constraint( + .validate_parametric_constraint( ConstraintKind::DistanceX, &refs, Some(&DrivingValue::Literal(f64::NAN)), @@ -1775,9 +2319,47 @@ mod tests { .is_err()); } + #[test] + fn ray_and_construction_line_constraints_solve_through_the_kernel() { + let mut scene = Scene::new(); + let ray = scene.add_entity(EntityType::Ray(acadrust::entities::Ray::new( + Vector3::ZERO, + Vector3::new(1.0, 1.0, 0.0), + ))); + let xline = scene.add_entity(EntityType::XLine(acadrust::entities::XLine::new( + Vector3::new(5.0, 5.0, 0.0), + Vector3::new(1.0, 1.0, 0.0), + ))); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(ray)], + None, + ); + set.add( + ConstraintKind::Vertical, + vec![ParametricRef::whole(xline)], + None, + ); + + scene.refresh_parametric_constraints(&[ + (ray, super::ChangeKind::Modified), + (xline, super::ChangeKind::Modified), + ]); + + let Some(EntityType::Ray(ray)) = scene.document.get_entity(ray) else { + panic!("expected ray") + }; + assert!(ray.direction.y.abs() < 1e-7); + let Some(EntityType::XLine(xline)) = scene.document.get_entity(xline) else { + panic!("expected construction line") + }; + assert!(xline.direction.x.abs() < 1e-7); + } + // The bulk of this stage's coverage (Horizontal/Parallel/Distance/ // Coincident solving, unrelated edits not triggering a resolve) lives - // in `tests/sketch_constraints_solve.rs`, which only needs `pub` API. + // in `tests/parametric_constraints_solve.rs`, which only needs `pub` API. // This one test needs `record_undo_before`/`take_undo_recording` // (`pub(crate)`), so it stays internal. #[test] @@ -1792,13 +2374,13 @@ mod tests { Vector3::new(10.0, 5.0, 0.0), ))); - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); set.add( ConstraintKind::Parallel, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); - scene.sketch_constraints.push(set); + scene.parametric_constraints.push(set); scene.begin_undo_recording(); // A real command (Move, grip-drag commit, ...) records its own @@ -1813,7 +2395,7 @@ mod tests { .take_undo_recording() .expect("an undo recording should still be open"); - let (entities, _objects, _sketch_constraints, _named_parameters) = + let (entities, _objects, _parametric_constraints, _named_parameters) = recording.into_recorded_images(); let touched: std::collections::HashSet<_> = entities.iter().map(|(h, _)| *h).collect(); assert!( @@ -1840,15 +2422,15 @@ mod tests { Vector3::new(20.0, 5.0, 0.0), ))); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Coincident, - vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], + vec![ParametricRef::point(a, 1), ParametricRef::point(b, 0)], None, ); assert_eq!( scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap() .constraints .len(), @@ -1861,7 +2443,7 @@ mod tests { // Ensure the test exercises restoration rather than a no-op. assert_eq!( scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .map_or(0, |s| s.constraints.len()), 0, "the constraint touching the erased line should be gone from the live set" @@ -1870,15 +2452,15 @@ mod tests { let recording = scene .take_undo_recording() .expect("an undo recording should still be open"); - let (_entities, _objects, sketch_constraints, _named_parameters) = + let (_entities, _objects, parametric_constraints, _named_parameters) = recording.into_recorded_images(); assert_eq!( - sketch_constraints.len(), + parametric_constraints.len(), 1, "the touched scope's before-image must be captured" ); - let (scope, before) = &sketch_constraints[0]; - assert_eq!(*scope, SketchScope::ModelSpace); + let (scope, before) = ¶metric_constraints[0]; + assert_eq!(*scope, ParametricScope::ModelSpace); assert_eq!( before.constraints.len(), 1, @@ -1887,15 +2469,15 @@ mod tests { assert_eq!(before.constraints[0].kind, ConstraintKind::Coincident); assert_eq!( before.constraints[0].refs, - vec![SketchRef::point(a, 1), SketchRef::point(b, 0)] + vec![ParametricRef::point(a, 1), ParametricRef::point(b, 0)] ); // What `apply_delta_state` does with this on undo: install the // before-image back as the live scope state. - *scene.sketch_constraint_set_mut(*scope) = before.clone(); + *scene.parametric_constraint_set_mut(*scope) = before.clone(); assert_eq!( scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap() .constraints .len(), @@ -1904,7 +2486,7 @@ mod tests { ); } - /// `solve_sketch_constraints_preview` reports the constrained + /// `solve_parametric_constraints_preview` reports the constrained /// neighbor's solved position without writing anything into the /// document itself — the grip-drag caller owns applying it. #[test] @@ -1918,13 +2500,13 @@ mod tests { Vector3::new(0.0, 5.0, 0.0), Vector3::new(10.0, 5.0, 0.0), ))); - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); set.add( ConstraintKind::Parallel, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); - scene.sketch_constraints.push(set); + scene.parametric_constraints.push(set); // Simulate a live grip drag: mutate `a` directly (as `apply_grip` // would each frame), without going through `bump_entities` at all. @@ -1933,7 +2515,7 @@ mod tests { } let b_before = scene.document.get_entity(b).cloned(); - let solved = scene.solve_sketch_constraints_preview(&[a]); + let solved = scene.solve_parametric_constraints_preview(&[a]); assert_eq!( scene.document.get_entity(b), @@ -1969,6 +2551,132 @@ mod tests { ); } + #[test] + fn rigid_set_preserves_relative_geometry_after_one_member_moves() { + let mut scene = Scene::new(); + let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 0.0), + ))); + let b = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, 5.0, 0.0), + Vector3::new(10.0, 5.0, 0.0), + ))); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); + set.add( + ConstraintKind::RigidSet, + vec![ParametricRef::whole(a), ParametricRef::whole(b)], + None, + ); + set.constraints[0].rigid_points = vec![ + (ParametricRef::point(a, 0), Vector3::new(0.0, 0.0, 0.0)), + (ParametricRef::point(a, 1), Vector3::new(10.0, 0.0, 0.0)), + (ParametricRef::point(b, 0), Vector3::new(0.0, 5.0, 0.0)), + (ParametricRef::point(b, 1), Vector3::new(10.0, 5.0, 0.0)), + ]; + scene.parametric_constraints.push(set); + + if let Some(EntityType::Line(line)) = scene.document.get_entity_mut(b) { + line.start.y += 10.0; + line.end.y += 10.0; + } + scene.bump_entities(&[(b, super::ChangeKind::Modified)]); + + let endpoints = |handle| { + let EntityType::Line(line) = scene.document.get_entity(handle).unwrap() else { + panic!("expected a line") + }; + (line.start, line.end) + }; + let (a0, a1) = endpoints(a); + let (b0, b1) = endpoints(b); + let distance = |first: Vector3, second: Vector3| { + let delta = second - first; + (delta.x * delta.x + delta.y * delta.y).sqrt() + }; + assert!((distance(a0, a1) - 10.0).abs() < 1.0e-6); + assert!((distance(b0, b1) - 10.0).abs() < 1.0e-6); + assert!((distance(a0, b0) - 5.0).abs() < 1.0e-6); + assert!((distance(a1, b1) - 5.0).abs() < 1.0e-6); + } + + #[test] + fn implicit_midpoint_recomputes_with_its_parent_line() { + let mut scene = Scene::new(); + let carrier = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 0.0), + ))); + let anchor = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(7.0, 3.0, 0.0), + Vector3::new(7.0, 8.0, 0.0), + ))); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Fixed, + vec![ParametricRef::whole(anchor)], + None, + ); + set.add( + ConstraintKind::Coincident, + vec![ + ParametricRef::point(carrier, -2), + ParametricRef::point(anchor, 0), + ], + None, + ); + + scene.bump_entities(&[(carrier, super::ChangeKind::Modified)]); + + let EntityType::Line(line) = scene.document.get_entity(carrier).unwrap() else { + panic!("expected line"); + }; + let midpoint = (line.start + line.end) * 0.5; + assert!((midpoint - Vector3::new(7.0, 3.0, 0.0)).length() < 1.0e-7); + } + + #[test] + fn spline_endpoint_participates_in_the_kernel_constraint_system() { + let mut scene = Scene::new(); + let spline = scene.add_entity(EntityType::Spline( + acadrust::entities::Spline::from_control_points( + 3, + vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(2.0, 1.0, 0.0), + Vector3::new(4.0, 1.0, 0.0), + Vector3::new(6.0, 0.0, 0.0), + ], + ), + )); + let anchor = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(9.0, 4.0, 0.0), + Vector3::new(9.0, 7.0, 0.0), + ))); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Fixed, + vec![ParametricRef::whole(anchor)], + None, + ); + set.add( + ConstraintKind::Coincident, + vec![ + ParametricRef::point(spline, 1), + ParametricRef::point(anchor, 0), + ], + None, + ); + + scene.bump_entities(&[(anchor, super::ChangeKind::Modified)]); + + let EntityType::Spline(spline) = scene.document.get_entity(spline).unwrap() else { + panic!("expected spline"); + }; + let end = spline.control_points.last().copied().unwrap(); + assert!((end - Vector3::new(9.0, 4.0, 0.0)).length() < 1.0e-7); + } + #[test] fn smooth_constraint_recomputes_the_spline_after_its_target_moves() { let mut scene = Scene::new(); @@ -1987,10 +2695,13 @@ mod tests { spline.knots = cadkernel::space::clamped_uniform_knots(3, 4); let spline = scene.add_entity(EntityType::Spline(spline)); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Smooth, - vec![SketchRef::point(spline, 0), SketchRef::point(target, 0)], + vec![ + ParametricRef::point(spline, 0), + ParametricRef::point(target, 0), + ], None, ); diff --git a/src/scene/sketch_persist.rs b/src/scene/sketch_persist.rs deleted file mode 100644 index 00a6404e7..000000000 --- a/src/scene/sketch_persist.rs +++ /dev/null @@ -1,393 +0,0 @@ -//! Persists each sketch constraint set in a versioned, chunked XRecord. - -use super::sketch_constraints::SketchConstraintSet; -use super::Scene; -use acadrust::{CadDocument, Handle}; -use bincode::Options; - -const XRECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS"; - -/// The vendored DWG writer's `XRecordValue::Chunk` encoder packs a single -/// `u8` length prefix per entry and silently truncates anything longer -/// (`cadcodec`'s `io/dwg/dwg_stream_writers/object_writer/objects.rs`, -/// `encode_xrecord_entries`) — unlike the DXF writer, which has no such -/// limit. A constraint set past a handful of entries serializes past 255 -/// bytes easily, so a single `XRecordEntry` silently loses the tail and -/// `bincode::deserialize` then fails on the truncated bytes, dropping the -/// *entire* scope's constraints on the next DWG load — reproduced and -/// root-caused live (not from a change log): a 5-entry set round-tripped -/// clean, a 6-entry set of the same shapes lost everything. Splitting the -/// blob across as many same-code `310` entries as needed (below) works -/// around it without touching the vendored crate — `CadDocument::xrecord`'s -/// `entries: Vec` already supports repeating a code. -const MAX_CHUNK_BYTES: usize = u8::MAX as usize; -const MAX_RECORD_BYTES: usize = 8 * 1024 * 1024; - -/// Removes a named XRecord from `owner`'s extension dictionary, if present. -/// The dictionary itself is left in place even if now empty — other code may -/// already reference it via `xdictionary_handle`, and an empty extension -/// dictionary is harmless bookkeeping, unlike a stale XRecord that would -/// resurrect deleted data on the next load. -fn remove_xrecord(document: &mut CadDocument, owner: Handle, key: &str) { - let Some(dictionary_handle) = document.extension_dictionary_handle(owner) else { - return; - }; - let Some(acadrust::objects::ObjectType::Dictionary(dictionary)) = - document.objects.get_mut(&dictionary_handle) - else { - return; - }; - let Some(index) = dictionary - .entries - .iter() - .position(|(name, _)| name.eq_ignore_ascii_case(key)) - else { - return; - }; - let (_, record_handle) = dictionary.entries.remove(index); - document.objects.remove(&record_handle); -} - -/// Serialized schema version. Unknown versions are skipped without affecting -/// the rest of the drawing. -const FORMAT_VERSION: u8 = 2; - -fn encode(set: &SketchConstraintSet) -> Option> { - let mut bytes = vec![FORMAT_VERSION]; - let body = bincode::serialize(set).ok()?; - if body.len() >= MAX_RECORD_BYTES { - return None; - } - bytes.extend(body); - Some(bytes) -} - -fn decode(bytes: &[u8]) -> Option { - let (&version, body) = bytes.split_first()?; - if version != FORMAT_VERSION { - return None; - } - bincode::DefaultOptions::new() - .with_fixint_encoding() - .allow_trailing_bytes() - .with_limit(MAX_RECORD_BYTES as u64) - .deserialize(body) - .ok() -} - -impl Scene { - /// Writes every current `sketch_constraints` entry into its scope's - /// owner-handle XRecord, ready for whatever save call happens next to - /// serialize `self.document` as-is. Called right before a native/web - /// save (`OpenCADStudio::prepare_native_save` and the wasm save path) - /// — see those call sites' doc comments for why "right before save" - /// rather than keeping the XRecord resident and live-updated (design - /// doc §5.2's "live" model, deferred: it would also let constraint-set - /// edits ride the ordinary object-delta undo path per §5.1(b), but this - /// lazy model was the doc's own recommended first step). - /// - /// A scope with an empty constraint set writes no XRecord — an - /// unconstrained drawing (the common case) should not gain - /// persisted-but-empty bookkeeping on every save. If a *previous* save - /// already materialized a non-empty XRecord for that scope, the now-empty - /// set actively removes it instead: leaving the stale blob in place would - /// resurrect the deleted constraints the next time the document loads. - pub(crate) fn materialize_sketch_constraints_for_save(&mut self) { - for index in 0..self.sketch_constraints.len() { - let set = &self.sketch_constraints[index]; - if set.constraints.is_empty() { - let owner = set.scope.owner_handle(&self.document); - if !owner.is_null() { - remove_xrecord(&mut self.document, owner, XRECORD_KEY); - } - continue; - } - let owner = set.scope.owner_handle(&self.document); - if owner.is_null() { - continue; - } - let Some(bytes) = encode(set) else { continue }; - self.document.ensure_xrecord(owner, XRECORD_KEY); - if let Some(record) = self.document.xrecord_mut(owner, XRECORD_KEY) { - // Overwrite, not append: a resave must replace the prior - // blob, not accumulate more Chunk entries every time. - record.entries.clear(); - for chunk in bytes.chunks(MAX_CHUNK_BYTES) { - record.entries.push(acadrust::objects::XRecordEntry::new( - 310, - acadrust::objects::XRecordValue::Chunk(chunk.to_vec()), - )); - } - } - } - } - - /// Populates `sketch_constraints` from every `OCS_SKETCH_CONSTRAINTS` - /// XRecord found in the just-loaded `self.document` — called right - /// after a document open installs its `CadDocument` into this `Scene` - /// (`OpenCADStudio::on_file_opened` and the automation `"open"`/`"new"` - /// ops). Every `BlockRecord` (model space, paper space, and named - /// block definitions) is a candidate owner; most will have no such - /// XRecord and are skipped cheaply. Replaces whatever was already in - /// `sketch_constraints` (a fresh/empty `Vec` for a real open; `"new"`'s - /// blank document has nothing to find anyway). - pub(crate) fn load_sketch_constraints_from_document(&mut self) { - self.sketch_constraints.clear(); - let owners: Vec = self - .document - .block_records - .iter() - .map(|record| record.handle) - .collect(); - for owner in owners { - if let Some(record) = self.document.xrecord(owner, XRECORD_KEY) { - let mut bytes = Vec::new(); - for entry in &record.entries { - if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value { - if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES { - bytes.clear(); - break; - } - bytes.extend_from_slice(chunk); - } - } - if let Some(set) = decode(&bytes) { - self.sketch_constraints.push(set); - } - continue; - } - - if let Some(set) = super::dwg_native_constraints::native_constraint_set( - &self.document, - owner, - &mut self.named_parameters, - ) { - self.sketch_constraints.push(set); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::super::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; - use super::*; - use acadrust::entities::EntityType; - use acadrust::types::Vector3; - - #[test] - fn a_model_space_constraint_set_survives_materialize_and_reload() { - let mut scene = Scene::new(); - let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(10.0, 0.0, 0.0), - ))); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); - - scene.materialize_sketch_constraints_for_save(); - // Simulate the load-time side of a save/reload round trip: drop the - // in-memory sets (a fresh open starts with none) and repopulate - // purely from what materialize just wrote into `document.objects`. - scene.sketch_constraints.clear(); - scene.load_sketch_constraints_from_document(); - - let set = scene - .sketch_constraint_set(SketchScope::ModelSpace) - .expect("constraint set should have been reloaded"); - assert_eq!(set.constraints.len(), 1); - assert_eq!(set.constraints[0].kind, ConstraintKind::Horizontal); - assert_eq!(set.constraints[0].refs, vec![SketchRef::whole(a)]); - } - - /// The above test only proves `materialize`/`load` agree with each - /// other on the same in-memory `CadDocument` — it never actually - /// serializes anything. This drives a real `save_to_bytes`/`load_bytes` - /// round trip (the same primitives `OpenCADStudio::on_file_opened`'s - /// native save/open path uses), through both supported formats, closing - /// the gap stage 2's spike proved for the raw XRecord mechanism up to - /// this stage's actual app-level wiring. - fn full_bytes_roundtrip(ext: &str) -> SketchConstraintSet { - let mut scene = Scene::new(); - let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(10.0, 5.0, 0.0), - ))); - let b = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( - Vector3::new(10.0, 5.0, 0.0), - Vector3::new(20.0, 5.0, 0.0), - ))); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add( - ConstraintKind::Coincident, - vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], - None, - ); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add( - ConstraintKind::Distance, - vec![SketchRef::whole(b)], - Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)), - ); - - scene.materialize_sketch_constraints_for_save(); - let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) - .unwrap_or_else(|e| panic!("save to {ext} bytes: {e}")); - let mut reloaded_scene = Scene::new(); - reloaded_scene.document = crate::io::load_bytes(&format!("stage4_roundtrip.{ext}"), bytes) - .unwrap_or_else(|e| panic!("reload {ext} bytes: {e}")); - reloaded_scene.load_sketch_constraints_from_document(); - - reloaded_scene - .sketch_constraint_set(SketchScope::ModelSpace) - .cloned() - .unwrap_or_else(|| { - panic!("no ModelSpace constraint set survived the {ext} save/load round trip") - }) - } - - #[test] - fn a_constraint_set_survives_a_real_dxf_save_and_load_round_trip() { - let restored = full_bytes_roundtrip("dxf"); - assert_eq!(restored.constraints.len(), 2); - assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident); - assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance); - assert_eq!( - restored.constraints[1].driving_param, - Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)) - ); - } - - #[test] - fn a_constraint_set_survives_a_real_dwg_save_and_load_round_trip() { - let restored = full_bytes_roundtrip("dwg"); - assert_eq!(restored.constraints.len(), 2); - assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident); - assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance); - assert_eq!( - restored.constraints[1].driving_param, - Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)) - ); - } - - #[test] - fn a_block_scoped_constraint_set_survives_materialize_and_reload() { - let mut scene = Scene::new(); - let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(1.0, 0.0, 0.0), - ))); - // `create_block_from_entities` returns the placed INSERT's handle, - // not the BlockRecord's own handle — `SketchScope::Block` needs the - // latter (it's what `BlockEditSession::br_handle` and - // `SketchScope::owner_handle` both mean by "a block definition's - // handle"), so look it up from the table by name instead of using - // the return value directly. - let _insert_handle = scene - .create_block_from_entities( - &[a], - "OCS_STAGE4_TEST_BLOCK", - &acadrust::types::Transform::identity(), - &acadrust::types::Transform::identity(), - ) - .expect("create a block definition to own the constraint set"); - let br_handle = scene - .document - .block_records - .get("OCS_STAGE4_TEST_BLOCK") - .expect("block record should exist") - .handle; - scene - .sketch_constraint_set_mut(SketchScope::Block(br_handle)) - .add(ConstraintKind::Vertical, vec![SketchRef::whole(a)], None); - - scene.materialize_sketch_constraints_for_save(); - scene.sketch_constraints.clear(); - scene.load_sketch_constraints_from_document(); - - let set = scene - .sketch_constraint_set(SketchScope::Block(br_handle)) - .expect("block-scoped constraint set should have been reloaded"); - assert_eq!(set.constraints.len(), 1); - assert_eq!(set.constraints[0].kind, ConstraintKind::Vertical); - } - - #[test] - fn an_empty_constraint_set_is_not_materialized() { - // A scope touched (e.g. `sketch_constraint_set_mut` called once, - // then every constraint removed) but left with zero constraints - // should not persist an empty XRecord. - let mut scene = Scene::new(); - let _ = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - scene.materialize_sketch_constraints_for_save(); - - let owner = scene.document.header.model_space_block_handle; - assert!( - scene.document.xrecord(owner, XRECORD_KEY).is_none(), - "an empty constraint set should not create an XRecord" - ); - } - - #[test] - fn clearing_a_previously_materialized_set_removes_its_stale_xrecord() { - // Regression for the audit-flagged case `an_empty_constraint_set_is_not_materialized` - // doesn't cover: a scope that already had a *non-empty* XRecord from a - // prior save must have that XRecord actively removed once its - // in-memory set goes back to empty — otherwise the stale blob - // survives the save and resurrects the deleted constraints on the - // next load. - let mut scene = Scene::new(); - let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(10.0, 0.0, 0.0), - ))); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); - scene.materialize_sketch_constraints_for_save(); - - let owner = scene.document.header.model_space_block_handle; - assert!( - scene.document.xrecord(owner, XRECORD_KEY).is_some(), - "sanity: the first save should have materialized an XRecord" - ); - - // Clear every constraint (mirrors deleting the constrained entity, or - // removing the last constraint by hand) and save again. - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .constraints - .clear(); - scene.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" - ); - - // And the load path must agree: nothing resurrects on reload. - scene.sketch_constraints.clear(); - scene.load_sketch_constraints_from_document(); - assert!( - scene - .sketch_constraint_set(SketchScope::ModelSpace) - .is_none(), - "no constraints should come back after the set was cleared and re-saved" - ); - } - - #[test] - fn decode_rejects_a_mismatched_format_version() { - let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)]; - bytes.extend( - bincode::serialize(&SketchConstraintSet::new(SketchScope::ModelSpace)).unwrap(), - ); - assert!( - decode(&bytes).is_none(), - "a future/unknown format version must be rejected, not misread" - ); - } -} diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs index d37fb157b..52ec2916e 100644 --- a/src/ui/statusbar/mod.rs +++ b/src/ui/statusbar/mod.rs @@ -1,13 +1,11 @@ //! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info +pub mod status_menu; pub mod statusbar_config; pub mod statusbar_menu; -pub mod status_menu; use iced::widget::tooltip::Position as TipPos; -use iced::widget::{ - button, column, container, mouse_area, row, text, text_input, tooltip, -}; +use iced::widget::{button, column, container, mouse_area, row, text, text_input, tooltip}; use iced::{Background, Border, Color, Element, Length, Theme}; use iced_aw::ContextMenu; use std::sync::Arc; @@ -23,10 +21,10 @@ pub const LAYOUT_RENAME_INPUT_ID: &str = "layout_rename_input"; use crate::app::Message; use crate::snap::Snapper; -use crate::ui::statusbar::statusbar_config::{StatusBarConfig, StatusPill}; -use crate::ui::statusbar::status_menu::Entry as StatusMenuEntry; -use crate::ui::wrap_bar::WrapBar; use crate::t; +use crate::ui::statusbar::status_menu::Entry as StatusMenuEntry; +use crate::ui::statusbar::statusbar_config::{StatusBarConfig, StatusPill}; +use crate::ui::wrap_bar::WrapBar; /// Height of one status-bar row. Matches the drawing-tab strip above it so the /// three horizontal strips — tabs, status bar, command line — line up, and it @@ -125,16 +123,16 @@ impl StatusBar { // Which pills the user has chosen to show on the bar. config: &'a StatusBarConfig, menu_data: StatusMenuData<'a>, - // Remaining degrees of freedom for the current sketch scope. + // Remaining degrees of freedom for the current parametric scope. // `None` when the scope - // has no `SketchConstraintSet` yet (nothing constrained), so the + // has no `ParametricConstraintSet` yet (nothing constrained), so the // badge stays invisible until it's actually relevant. - sketch_dof: Option, + parametric_dof: Option, // Number of redundant/conflicting constraints in the current scope. // Zero hides // the pill entirely, so an ordinarily/fully-constrained drawing sees // no new clutter. - sketch_conflicts: usize, + parametric_conflicts: usize, // What is drawing the scene. Only a degraded verdict shows anything. gpu_status: &'a crate::scene::pipeline::GpuStatus, ) -> Element<'a, Message> { @@ -157,9 +155,7 @@ impl StatusBar { } else { crate::ui::icons::themed_secondary(crate::ui::icons::MENU, 16.0) }; - let menu_button = button(menu_icon) - .style(button::subtle) - .padding([4, 8]); + let menu_button = button(menu_icon).style(button::subtle).padding([4, 8]); let menu_btn = if is_start { tip( menu_button.into(), @@ -270,9 +266,9 @@ impl StatusBar { ); } // Not a `StatusPill` (so not user-hideable yet): invisible until the - // current scope actually has a SketchConstraintSet, so a drawing + // current scope actually has a ParametricConstraintSet, so a drawing // that never uses parametric constraints sees no new clutter. - if let Some(dof) = sketch_dof { + if let Some(dof) = parametric_dof { let pill = if dof == 0 { success_pill(crate::tf!("DOF: {dof}").into_owned()) } else { @@ -284,21 +280,21 @@ impl StatusBar { if dof == 0 { t!("Fully constrained — no remaining degrees of freedom") } else { - t!("Remaining degrees of freedom in the current sketch's persistent constraints") + t!("Remaining degrees of freedom in the current parametric constraint scope") }, ) .into(), ); } // Each click removes one flagged constraint. - if sketch_conflicts > 0 { + if parametric_conflicts > 0 { pills.push( tip( action_pill( - crate::tf!("⚠ {sketch_conflicts} conflicting").into_owned(), - Message::ResolveOneSketchConflict, + crate::tf!("⚠ {parametric_conflicts} conflicting").into_owned(), + Message::ResolveOneParametricConflict, ), - t!("One or more constraints in this sketch conflict or are redundant\nClick to remove one and re-solve"), + t!("One or more parametric constraints conflict or are redundant\nClick to remove one and re-solve"), ) .into(), ); @@ -315,7 +311,11 @@ impl StatusBar { if vis(StatusPill::Lwt) { pills.push( tip( - toggle_pill(crate::ui::icons::ST_LWT, lineweight_display, Message::ToggleLineweightDisplay), + toggle_pill( + crate::ui::icons::ST_LWT, + lineweight_display, + Message::ToggleLineweightDisplay, + ), t!("Show Lineweight\nLWDISPLAY"), ) .into(), @@ -462,10 +462,7 @@ impl StatusBar { t!("Isolate Objects\nClick for Isolate / Hide / End"), tooltip_hidden, ), - crate::ui::popup::isolate_popup::menu_entries( - has_selection, - isolation_active, - ), + crate::ui::popup::isolate_popup::menu_entries(has_selection, isolation_active), 160.0, ) .into(), @@ -474,7 +471,11 @@ impl StatusBar { if vis(StatusPill::QuickProps) { pills.push( tip( - toggle_pill(crate::ui::icons::ST_QUICKPROPS, quick_properties, Message::ToggleQuickProperties), + toggle_pill( + crate::ui::icons::ST_QUICKPROPS, + quick_properties, + Message::ToggleQuickProperties, + ), t!("Quick Properties\nFloating panel on selection"), ) .into(), @@ -504,7 +505,11 @@ impl StatusBar { if vis(StatusPill::SelCycle) { pills.push( tip( - toggle_pill(crate::ui::icons::ST_SELCYCLE, selection_cycling, Message::ToggleSelectionCycling), + toggle_pill( + crate::ui::icons::ST_SELCYCLE, + selection_cycling, + Message::ToggleSelectionCycling, + ), t!("Selection Cycling\nRepeat-click to step through overlapping objects"), ) .into(), @@ -522,7 +527,11 @@ impl StatusBar { if vis(StatusPill::CleanScreen) { pills.push( tip( - toggle_pill(crate::ui::icons::ST_CLEANSCREEN, clean_screen, Message::ToggleCleanScreen), + toggle_pill( + crate::ui::icons::ST_CLEANSCREEN, + clean_screen, + Message::ToggleCleanScreen, + ), t!("Clean Screen\nHide ribbon and panels"), ) .into(), @@ -607,13 +616,13 @@ impl StatusBar { .style(|theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color(palette.background.base.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 0.0.into(), - }, - ..Default::default() + background: Some(Background::Color(palette.background.base.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() } }) .width(Length::Fill) @@ -634,7 +643,12 @@ impl StatusBar { /// drawing set to architectural units read its coordinates in decimals, and one /// asking for two places got four. `format_length` is the same helper the /// properties panel formats through, so both now say a length the same way. -fn format_coords(cursor: glam::DVec3, last: Option, mode: i16, picking: bool) -> String { +fn format_coords( + cursor: glam::DVec3, + last: Option, + mode: i16, + picking: bool, +) -> String { use crate::entities::common::format_length as len; let abs = |p: glam::DVec3| format!("{}, {}, {}", len(p.x), len(p.y), len(p.z)); match mode { @@ -666,11 +680,14 @@ fn format_coords(cursor: glam::DVec3, last: Option, mode: i16, pick // ── Customization handle ────────────────────────────────────────────────── fn customize_btn() -> Element<'static, Message> { - button(crate::ui::icons::themed_secondary(crate::ui::icons::MENU, 16.0)) - .on_press(Message::StatusMenuTooltipHidden(true)) - .style(button::subtle) - .padding([4, 8]) - .into() + button(crate::ui::icons::themed_secondary( + crate::ui::icons::MENU, + 16.0, + )) + .on_press(Message::StatusMenuTooltipHidden(true)) + .style(button::subtle) + .padding([4, 8]) + .into() } // ── Tooltip helper ──────────────────────────────────────────────────────── @@ -689,11 +706,7 @@ fn menu_tip<'a>( label: std::borrow::Cow<'static, str>, hidden: bool, ) -> Element<'a, Message> { - let content = if hidden { - content - } else { - tip(content, label) - }; + let content = if hidden { content } else { tip(content, label) }; mouse_area(content) .on_exit(Message::StatusMenuTooltipHidden(false)) @@ -763,21 +776,21 @@ fn split_pill<'a>( .style(move |theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color(if active { - palette.primary.weak.color - } else { - palette.background.weakest.color - })), - border: Border { - color: if active { - palette.primary.base.color + background: Some(Background::Color(if active { + palette.primary.weak.color } else { - palette.background.neutral.color + palette.background.weakest.color + })), + border: Border { + color: if active { + palette.primary.base.color + } else { + palette.background.neutral.color + }, + width: 1.0, + radius: 2.0.into(), }, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() + ..Default::default() } }) .padding([4, 6]) @@ -880,8 +893,8 @@ fn osnap_btn<'a>( }; let main = tip( mouse_area(snap_icon) - .on_press(Message::ToggleSnapEnabled) - .into(), + .on_press(Message::ToggleSnapEnabled) + .into(), t!("Object Snap: toggle on/off\nF3"), ); @@ -926,14 +939,10 @@ fn layout_tab_context_menu(name: String) -> Element<'static, Message> { .on_press(Message::LayoutDelete(name)) .interaction(iced::mouse::Interaction::Pointer); - container( - column![rename, delete] - .spacing(0) - .width(160), - ) - .style(container::bordered_box) - .padding([4, 0]) - .into() + container(column![rename, delete].spacing(0).width(160)) + .style(container::bordered_box) + .padding([4, 0]) + .into() } /// A layout tab button. @@ -1020,12 +1029,7 @@ fn space_tab<'a>( let report_key = format!("{report_key_prefix}:{label}"); let tab = mouse_area(display).on_press(switch_msg); let tab: Element<'a, Message> = if has_context_menu { - crate::ui::wrap_bar::ReorderTab::layout( - label.clone(), - reorderable_layouts, - tab, - ) - .into() + crate::ui::wrap_bar::ReorderTab::layout(label.clone(), reorderable_layouts, tab).into() } else { tab.into() }; @@ -1035,11 +1039,7 @@ fn space_tab<'a>( } else { tab }; - crate::ui::wrap_bar::PosReport::owned( - report_key, - tab, - ) - .into() + crate::ui::wrap_bar::PosReport::owned(report_key, tab).into() } } @@ -1051,7 +1051,7 @@ fn space_tab<'a>( fn space_mode_btn(current_layout: &str, in_mspace: bool) -> Element<'static, Message> { let is_model_tab = current_layout == "Model"; - // Labels and styling follow AutoCAD convention: + // Labels and styling follow the conventional drafting layout: // PAPER = currently in paper-space editing // MODEL = currently in model-space editing (either the Model tab or MSPACE) let (label, active, on_press) = if is_model_tab { @@ -1089,9 +1089,9 @@ fn space_mode_btn(current_layout: &str, in_mspace: bool) -> Element<'static, Mes fn status_pill(label: impl Into) -> Element<'static, Message> { container(text(label.into()).size(12)) - .style(container::bordered_box) - .padding([4, 8]) - .into() + .style(container::bordered_box) + .padding([4, 8]) + .into() } /// A success-colored status pill used when no degrees of freedom remain. @@ -1123,7 +1123,11 @@ fn success_pill(label: impl Into) -> Element<'static, Message> { container::Style { background: Some(Background::Color(palette.success.weak.color)), text_color: Some(palette.success.weak.text), - border: Border { color: palette.success.base.color, width: 1.0, radius: 4.0.into() }, + border: Border { + color: palette.success.base.color, + width: 1.0, + radius: 4.0.into(), + }, ..Default::default() } }) @@ -1141,10 +1145,10 @@ fn popup_pill(label: impl Into) -> Element<'static, Message> { fn action_pill(label: impl Into, msg: Message) -> Element<'static, Message> { let label = label.into(); button(text(label).size(12)) - .on_press(msg) - .style(button::subtle) - .padding([4, 7]) - .into() + .on_press(msg) + .style(button::subtle) + .padding([4, 7]) + .into() } // ── Scale display ───────────────────────────────────────────────────────── @@ -1196,13 +1200,10 @@ fn active_scale_label( .iter() .find(|(_, anno_scale, vp_scale)| { if is_model { - (annotation_scale - *anno_scale).abs() - < 0.001 * annotation_scale.max(0.001) + (annotation_scale - *anno_scale).abs() < 0.001 * annotation_scale.max(0.001) } else { viewport_scale - .map(|current| { - (current - *vp_scale).abs() < 0.001 * vp_scale.max(0.001) - }) + .map(|current| (current - *vp_scale).abs() < 0.001 * vp_scale.max(0.001)) .unwrap_or(false) } }) diff --git a/src/ui/window/named_parameters.rs b/src/ui/window/named_parameters.rs index 35628ae0a..88afb3bf8 100644 --- a/src/ui/window/named_parameters.rs +++ b/src/ui/window/named_parameters.rs @@ -9,7 +9,7 @@ //! Unlike the alias editor, each row also shows a live-computed resolved //! value or error (`preview`, below) — built fresh from the whole buffer on //! every render, not cached: the same "cheap enough to recompute from -//! scratch" philosophy `ParameterTable`/`sketch_solve.rs` already use +//! scratch" philosophy `ParameterTable`/`parametric_solve.rs` already use //! throughout this project, and it means the preview reflects circular/ //! undefined-reference problems across rows immediately, before Apply. @@ -393,7 +393,9 @@ mod tests { #[test] fn usage_lines_names_the_constraint_kind_and_its_entities() { - use crate::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; + use crate::scene::parametric_constraints::{ + ConstraintKind, ParametricRef, ParametricScope, + }; let mut scene = Scene::new(); let line = scene.add_entity(acadrust::EntityType::Line( acadrust::entities::Line::from_points( @@ -402,10 +404,10 @@ mod tests { ), )); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Distance, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(crate::scene::named_parameters::DrivingValue::Named( "gap".to_string(), )), diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 44e0906a9..3c7f168c3 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -145,7 +145,6 @@ impl fmt::Display for Labelled { pub fn view_window<'a>( default_save_format: &'a str, file_assoc_enabled: bool, - write_dwg_native_constraints: bool, show_constraint_values: bool, ui_theme: &'a UiThemeConfig, theme_color_inputs: &'a [String; 6], @@ -352,22 +351,6 @@ pub fn view_window<'a>( .size(11) .width(sizing.width), Space::new().height(14), - row![ - iced::widget::checkbox(write_dwg_native_constraints) - .on_toggle(Message::WriteDwgNativeConstraintsChanged) - .size(15), - text(crate::t!("Write native constraint objects on save")).size(12), - ] - .spacing(8) - .align_y(iced::Center), - Space::new().height(6), - text(crate::t!( - "Saves sketch constraints as native drawing objects alongside this app's own format. \ - Off by default because it adds file size. Existing native objects stay synchronized." - )) - .size(11) - .width(sizing.width), - Space::new().height(14), row![ iced::widget::checkbox(show_constraint_values) .on_toggle(Message::ShowConstraintValuesChanged) diff --git a/tests/sketch_constraints_solve.rs b/tests/parametric_constraints_solve.rs similarity index 85% rename from tests/sketch_constraints_solve.rs rename to tests/parametric_constraints_solve.rs index ebb379c3d..ed71458a8 100644 --- a/tests/sketch_constraints_solve.rs +++ b/tests/parametric_constraints_solve.rs @@ -1,7 +1,9 @@ use acadrust::entities::EntityType; use acadrust::types::{Handle, Vector3}; use OpenCADStudio::scene::named_parameters::DrivingValue; -use OpenCADStudio::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; +use OpenCADStudio::scene::parametric_constraints::{ + ConstraintKind, ParametricRef, ParametricScope, +}; use OpenCADStudio::scene::{ChangeKind, Scene}; fn add_line(scene: &mut Scene, x1: f64, y1: f64, x2: f64, y2: f64) -> Handle { @@ -62,13 +64,6 @@ fn arc_geom(scene: &Scene, handle: Handle) -> (Vector3, f64) { } } -fn arc_angles(scene: &Scene, handle: Handle) -> (f64, f64, f64) { - match scene.document.get_entity(handle).expect("entity exists") { - EntityType::Arc(a) => (a.radius, a.start_angle, a.end_angle), - other => panic!("expected an Arc, got {other:?}"), - } -} - fn add_ellipse(scene: &mut Scene, cx: f64, cy: f64, major_axis: (f64, f64), ratio: f64) -> Handle { scene.add_entity(EntityType::Ellipse( acadrust::entities::Ellipse::from_center_axes( @@ -92,10 +87,10 @@ fn horizontal_constraint_levels_the_line_when_an_endpoint_moves() { let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Horizontal, - vec![SketchRef::whole(line)], + vec![ParametricRef::whole(line)], None, ); @@ -118,7 +113,7 @@ fn horizontal_constraint_levels_the_line_when_an_endpoint_moves() { // constraint-referenced params, so an untouched one was invisible to // `diagnose` entirely before this was fixed). let dof = scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .and_then(|s| s.dof); assert_eq!( dof, @@ -138,10 +133,10 @@ fn horizontal_constraint_levels_a_polyline_segment() { ]), )); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Horizontal, - vec![SketchRef::segment(polyline, 1)], + vec![ParametricRef::segment(polyline, 1)], None, ); @@ -163,10 +158,10 @@ fn horizontal_constraint_does_not_flatten_a_polyline_arc_segment() { let mut scene = Scene::new(); let polyline = scene.add_entity(EntityType::LwPolyline(source)); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Horizontal, - vec![SketchRef::segment(polyline, 0)], + vec![ParametricRef::segment(polyline, 0)], None, ); @@ -185,10 +180,10 @@ fn horizontal_constraint_aligns_two_selected_points() { let a = add_line(&mut scene, 0.0, 1.0, 4.0, 3.0); let b = add_line(&mut scene, 8.0, 7.0, 12.0, 9.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Horizontal, - vec![SketchRef::point(a, 0), SketchRef::point(b, 0)], + vec![ParametricRef::point(a, 0), ParametricRef::point(b, 0)], None, ); @@ -205,10 +200,10 @@ fn parallel_constraint_rotates_the_other_line_when_one_moves() { let b = add_line(&mut scene, 0.0, 5.0, 10.0, 5.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Parallel, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); @@ -234,10 +229,10 @@ fn distance_constraint_holds_the_target_length_after_an_unrelated_endpoint_edit( let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Distance, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(DrivingValue::Literal(20.0)), ); @@ -265,10 +260,10 @@ fn distance_constraint_resolves_a_named_parameter_reference() { .unwrap(); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Distance, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(DrivingValue::Named("target_len".to_string())), ); @@ -307,10 +302,10 @@ fn distance_constraint_with_an_undefined_named_reference_is_skipped_not_panicked let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Distance, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(DrivingValue::Named("does_not_exist".to_string())), ); @@ -331,10 +326,10 @@ fn tangent_constraint_between_a_line_and_a_circle_solves_to_touching() { let line = add_line(&mut scene, -10.0, 10.0, 10.0, 10.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Tangent, - vec![SketchRef::whole(circle), SketchRef::whole(line)], + vec![ParametricRef::whole(circle), ParametricRef::whole(line)], None, ); scene.bump_entities(&[(circle, ChangeKind::Modified), (line, ChangeKind::Modified)]); @@ -355,10 +350,10 @@ fn tangent_constraint_between_two_circles_solves_to_external_tangency() { let b = add_circle(&mut scene, 20.0, 0.0, 2.0); // far apart: distance 20, r1+r2 = 5 scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Tangent, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); scene.bump_entities(&[(a, ChangeKind::Modified), (b, ChangeKind::Modified)]); @@ -380,10 +375,10 @@ fn coincident_constraint_pulls_the_second_point_onto_the_first_when_it_moves() { let b = add_line(&mut scene, 5.0, 0.0, 5.0, 5.0); // b.start coincident with a.end scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Coincident, - vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], + vec![ParametricRef::point(a, 1), ParametricRef::point(b, 0)], None, ); @@ -406,10 +401,10 @@ fn unrelated_entity_edit_does_not_touch_constrained_geometry() { let unrelated = add_line(&mut scene, 100.0, 100.0, 200.0, 100.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Horizontal, - vec![SketchRef::whole(line)], + vec![ParametricRef::whole(line)], None, ); @@ -434,20 +429,24 @@ fn erasing_a_constrained_entity_drops_its_constraints_instead_of_dangling() { let c = add_line(&mut scene, 20.0, 20.0, 30.0, 20.0); // unrelated, own constraint let parallel_id = scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Parallel, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); let horizontal_id = scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(c)], None); + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(c)], + None, + ); scene.erase_entities(&[a]); let set = scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .expect("scope still exists"); assert!( set.get(parallel_id).is_none(), @@ -475,16 +474,16 @@ fn copying_two_constrained_entities_carries_their_constraint_along() { let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let b = add_line(&mut scene, 0.0, 5.0, 10.0, 3.0); // deliberately not parallel yet scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Parallel, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); // Solve once so the source pair is actually parallel before copying. scene.bump_entities(&[(a, ChangeKind::Modified), (b, ChangeKind::Modified)]); let constraints_before = scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap() .constraints .len(); @@ -496,7 +495,7 @@ fn copying_two_constrained_entities_carries_their_constraint_along() { let (new_a, new_b) = (new_handles[0], new_handles[1]); let constraints_after = scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap() .constraints .len(); @@ -534,25 +533,33 @@ fn copying_two_constrained_entities_carries_their_constraint_along() { // The "one edit that ripples through a constraint still records as one undo // step" case needs `Scene::record_undo_before`, which is `pub(crate)` (an // integration test crate can't reach it) — covered instead as an internal -// unit test in `src/scene/sketch_solve.rs`. +// unit test in `src/scene/parametric_solve.rs`. #[test] fn a_duplicated_horizontal_constraint_is_reported_as_redundant() { // `solve_scope` resolves the redundant row back to the // `ConstraintId` a `ConflictResolverPanel` would name. use cadkernel_constraints::diagnosis::RedundancyKind; - use OpenCADStudio::scene::sketch_constraints::ConstraintKind; + use OpenCADStudio::scene::parametric_constraints::ConstraintKind; let mut scene = Scene::new(); let a = add_line(&mut scene, 0.0, 0.0, 10.0, 3.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - let first = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); - let second = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); // exact duplicate + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + let first = set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(a)], + None, + ); + let second = set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(a)], + None, + ); // exact duplicate scene.bump_entities(&[(a, ChangeKind::Modified)]); let set = scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap(); assert_eq!( set.conflicts.len(), @@ -570,26 +577,26 @@ fn a_duplicated_horizontal_constraint_is_reported_as_redundant() { #[test] fn two_conflicting_distance_targets_are_reported_as_conflicting() { use cadkernel_constraints::diagnosis::RedundancyKind; - use OpenCADStudio::scene::sketch_constraints::ConstraintKind; + use OpenCADStudio::scene::parametric_constraints::ConstraintKind; let mut scene = Scene::new(); let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); let first = set.add( ConstraintKind::Distance, - vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], + vec![ParametricRef::point(a, 0), ParametricRef::point(a, 1)], Some(DrivingValue::Literal(20.0)), ); let second = set.add( ConstraintKind::Distance, - vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], + vec![ParametricRef::point(a, 0), ParametricRef::point(a, 1)], Some(DrivingValue::Literal(50.0)), ); scene.bump_entities(&[(a, ChangeKind::Modified)]); let set = scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap(); assert_eq!(set.conflicts.len(), 1); let (flagged_id, kind) = set.conflicts[0]; @@ -619,10 +626,10 @@ fn concentric_constraint_pulls_the_second_circles_center_onto_the_firsts() { let b = add_circle(&mut scene, 5.0, 5.0, 1.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Concentric, - vec![SketchRef::center(a), SketchRef::center(b)], + vec![ParametricRef::center(a), ParametricRef::center(b)], None, ); @@ -649,10 +656,10 @@ fn center_point_constraint_pulls_a_lines_endpoint_onto_a_circles_center() { let line = add_line(&mut scene, 10.0, 10.0, 20.0, 20.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::CenterPoint, - vec![SketchRef::point(line, 0), SketchRef::center(circle)], + vec![ParametricRef::point(line, 0), ParametricRef::center(circle)], None, ); @@ -674,10 +681,10 @@ fn colinear_constraint_pulls_the_second_line_onto_the_firsts_infinite_line() { let b = add_line(&mut scene, 3.0, 4.0, 7.0, 6.0); // off-axis, not on a's line scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Colinear, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); @@ -707,10 +714,10 @@ fn midpoint_constraint_pulls_a_point_onto_a_lines_midpoint() { let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 21.0); // marker.start is the tracked point scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Midpoint, - vec![SketchRef::point(marker, 0), SketchRef::whole(base)], + vec![ParametricRef::point(marker, 0), ParametricRef::whole(base)], None, ); @@ -734,10 +741,10 @@ fn midpoint_constraint_supports_a_point_entity() { Vector3::new(20.0, 20.0, 0.0), ))); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Midpoint, - vec![SketchRef::point(point, 0), SketchRef::whole(base)], + vec![ParametricRef::point(point, 0), ParametricRef::whole(base)], None, ); @@ -759,10 +766,13 @@ fn coincident_constraint_supports_a_block_insertion_point() { Vector3::new(30.0, 20.0, 0.0), ))); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Coincident, - vec![SketchRef::point(line, 1), SketchRef::point(insert, 0)], + vec![ + ParametricRef::point(line, 1), + ParametricRef::point(insert, 0), + ], None, ); @@ -781,17 +791,17 @@ fn fixed_constraint_holds_an_entity_in_place_despite_a_connected_edit() { let fixed_line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let moving_line = add_line(&mut scene, 10.0, 0.0, 10.0, 10.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); set.add( ConstraintKind::Fixed, - vec![SketchRef::whole(fixed_line)], + vec![ParametricRef::whole(fixed_line)], None, ); set.add( ConstraintKind::Coincident, vec![ - SketchRef::point(fixed_line, 1), - SketchRef::point(moving_line, 0), + ParametricRef::point(fixed_line, 1), + ParametricRef::point(moving_line, 0), ], None, ); @@ -825,10 +835,13 @@ fn point_on_curve_constraint_pulls_a_point_onto_a_circles_circumference() { let marker = add_line(&mut scene, 100.0, 100.0, 101.0, 101.0); // marker.start is the tracked point scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::PointOnCurve, - vec![SketchRef::point(marker, 0), SketchRef::whole(circle)], + vec![ + ParametricRef::point(marker, 0), + ParametricRef::whole(circle), + ], None, ); @@ -850,10 +863,13 @@ fn point_on_curve_constraint_pulls_a_point_onto_an_ellipse() { let marker = add_line(&mut scene, 12.0, 8.0, 13.0, 8.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::PointOnCurve, - vec![SketchRef::point(marker, 0), SketchRef::whole(ellipse)], + vec![ + ParametricRef::point(marker, 0), + ParametricRef::whole(ellipse), + ], None, ); scene.bump_entities(&[(marker, ChangeKind::Modified)]); @@ -879,10 +895,10 @@ fn tangent_constraint_solves_an_ellipse_and_line() { let line = add_line(&mut scene, -8.0, 8.0, 8.0, 8.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Tangent, - vec![SketchRef::whole(ellipse), SketchRef::whole(line)], + vec![ParametricRef::whole(ellipse), ParametricRef::whole(line)], None, ); scene.bump_entities(&[(line, ChangeKind::Modified)]); @@ -911,10 +927,10 @@ fn equal_constraint_matches_ellipse_major_axes() { let b = add_ellipse(&mut scene, 20.0, 0.0, (2.0, 0.0), 0.5); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Equal, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); scene.bump_entities(&[(b, ChangeKind::Modified)]); @@ -938,14 +954,14 @@ fn equal_distance_constraint_matches_a_second_point_pairs_separation() { let b = add_line(&mut scene, 20.0, 20.0, 25.0, 20.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::EqualDistance, vec![ - SketchRef::point(b, 0), - SketchRef::point(b, 1), - SketchRef::point(a, 0), - SketchRef::point(a, 1), + ParametricRef::point(b, 0), + ParametricRef::point(b, 1), + ParametricRef::point(a, 0), + ParametricRef::point(a, 1), ], None, ); @@ -972,21 +988,25 @@ fn symmetric_constraint_mirrors_one_circles_center_across_the_axis_line() { // scale dividing by zero at the very first solve iteration). let b = add_circle(&mut scene, 8.0, 9.0, 1.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); // Pin the axis and `a` in place — otherwise they're just as free to move // as `b`'s center, and with only 2 equations (MidpointOnLine + // Perpendicular) against that many more unknowns the solver is free to // converge on any of infinitely many valid configurations, not // necessarily "only b moves, to a's exact mirror" (which is the one // deterministic outcome this test actually wants to check). - set.add(ConstraintKind::Fixed, vec![SketchRef::whole(axis)], None); - set.add(ConstraintKind::Fixed, vec![SketchRef::whole(a)], None); + set.add( + ConstraintKind::Fixed, + vec![ParametricRef::whole(axis)], + None, + ); + set.add(ConstraintKind::Fixed, vec![ParametricRef::whole(a)], None); set.add( ConstraintKind::Symmetric, vec![ - SketchRef::center(a), - SketchRef::center(b), - SketchRef::whole(axis), + ParametricRef::center(a), + ParametricRef::center(b), + ParametricRef::whole(axis), ], None, ); @@ -1019,10 +1039,10 @@ fn radius_constraint_on_an_arc_solves_to_the_target() { let arc = add_arc(&mut scene, 0.0, 0.0, 3.0, 0.0, std::f64::consts::FRAC_PI_2); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Radius, - vec![SketchRef::whole(arc)], + vec![ParametricRef::whole(arc)], Some(DrivingValue::Literal(7.5)), ); scene.bump_entities(&[(arc, ChangeKind::Modified)]); @@ -1041,10 +1061,10 @@ fn tangent_constraint_between_a_line_and_an_arc_solves_to_touching() { let line = add_line(&mut scene, -10.0, 10.0, 10.0, 10.0); // clear of the arc's circle scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Tangent, - vec![SketchRef::whole(arc), SketchRef::whole(line)], + vec![ParametricRef::whole(arc), ParametricRef::whole(line)], None, ); scene.bump_entities(&[(arc, ChangeKind::Modified), (line, ChangeKind::Modified)]); @@ -1064,10 +1084,10 @@ fn concentric_constraint_between_an_arc_and_a_circle_pulls_centers_together() { let circle = add_circle(&mut scene, 10.0, -6.0, 2.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Concentric, - vec![SketchRef::center(arc), SketchRef::center(circle)], + vec![ParametricRef::center(arc), ParametricRef::center(circle)], None, ); scene.bump_entities(&[(arc, ChangeKind::Modified), (circle, ChangeKind::Modified)]); @@ -1102,10 +1122,10 @@ fn coincident_constraint_pulls_a_lines_endpoint_onto_an_arcs_start_point() { let line = add_line(&mut scene, 20.0, 20.0, 30.0, 30.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Coincident, - vec![SketchRef::point(arc, 0), SketchRef::point(line, 0)], + vec![ParametricRef::point(arc, 0), ParametricRef::point(line, 0)], None, ); @@ -1138,11 +1158,18 @@ fn an_arcs_endpoint_stays_consistent_with_its_center_radius_and_angle_after_solv let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI); let anchor = add_line(&mut scene, 12.0, 7.0, 12.0, 7.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add(ConstraintKind::Fixed, vec![SketchRef::whole(anchor)], None); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Fixed, + vec![ParametricRef::whole(anchor)], + None, + ); set.add( ConstraintKind::Coincident, - vec![SketchRef::point(arc, 0), SketchRef::point(anchor, 0)], + vec![ + ParametricRef::point(arc, 0), + ParametricRef::point(anchor, 0), + ], None, ); scene.bump_entities(&[(arc, ChangeKind::Modified)]); @@ -1176,10 +1203,10 @@ fn point_on_curve_constraint_pulls_a_point_onto_an_arcs_underlying_circle() { let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 20.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::PointOnCurve, - vec![SketchRef::point(marker, 0), SketchRef::whole(arc)], + vec![ParametricRef::point(marker, 0), ParametricRef::whole(arc)], None, ); scene.bump_entities(&[(marker, ChangeKind::Modified)]); @@ -1202,16 +1229,23 @@ fn radius_and_endpoint_coincident_constraints_coexist_on_the_same_arc() { let arc = add_arc(&mut scene, 0.0, 0.0, 3.0, 0.0, std::f64::consts::FRAC_PI_2); let anchor = add_line(&mut scene, 9.0, -4.0, 9.0, -4.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add(ConstraintKind::Fixed, vec![SketchRef::whole(anchor)], None); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Fixed, + vec![ParametricRef::whole(anchor)], + None, + ); set.add( ConstraintKind::Coincident, - vec![SketchRef::point(arc, 0), SketchRef::point(anchor, 0)], + vec![ + ParametricRef::point(arc, 0), + ParametricRef::point(anchor, 0), + ], None, ); set.add( ConstraintKind::Radius, - vec![SketchRef::whole(arc)], + vec![ParametricRef::whole(arc)], Some(DrivingValue::Literal(6.0)), ); scene.bump_entities(&[(arc, ChangeKind::Modified)]); @@ -1238,10 +1272,10 @@ fn diameter_constraint_drives_the_radius_to_half_the_target() { let circle = add_circle(&mut scene, 0.0, 0.0, 3.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Diameter, - vec![SketchRef::whole(circle)], + vec![ParametricRef::whole(circle)], Some(DrivingValue::Literal(16.0)), ); scene.bump_entities(&[(circle, ChangeKind::Modified)]); @@ -1261,10 +1295,10 @@ fn distance_x_constraint_pins_only_the_x_component() { let line = add_line(&mut scene, 0.0, 0.0, 6.0, 8.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::DistanceX, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(DrivingValue::Literal(10.0)), ); scene.bump_entities(&[(line, ChangeKind::Modified)]); @@ -1289,10 +1323,10 @@ fn normal_constraint_pulls_the_line_through_the_circles_center() { let line = add_line(&mut scene, -10.0, 0.0, 10.0, 0.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Normal, - vec![SketchRef::whole(circle), SketchRef::whole(line)], + vec![ParametricRef::whole(circle), ParametricRef::whole(line)], None, ); scene.bump_entities(&[(circle, ChangeKind::Modified), (line, ChangeKind::Modified)]); @@ -1308,42 +1342,6 @@ fn normal_constraint_pulls_the_line_through_the_circles_center() { ); } -// Phase 4a of the same plan: `ArcLength` registers an arc's -// `start_angle`/`end_angle` separately from its center/radius (which still -// flow through the existing `EntityGeom::Circle` aliasing), so it can -// combine with a `Radius` constraint on the same arc in one solve. - -#[test] -fn arc_length_constraint_drives_the_swept_length_to_the_target() { - let mut scene = Scene::new(); - // radius 5, a 90° span (length ≈ 7.85) — deliberately not the target. - let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::FRAC_PI_2); - - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add( - ConstraintKind::Radius, - vec![SketchRef::whole(arc)], - Some(DrivingValue::Literal(5.0)), - ); - set.add( - ConstraintKind::ArcLength, - vec![SketchRef::whole(arc)], - Some(DrivingValue::Literal(10.0)), - ); - scene.bump_entities(&[(arc, ChangeKind::Modified)]); - - let (radius, start_angle, end_angle) = arc_angles(&scene, arc); - assert!( - (radius - 5.0).abs() < 1e-6, - "Radius constraint should still hold: got {radius}" - ); - let length = radius * (end_angle - start_angle); - assert!( - (length - 10.0).abs() < 1e-5, - "swept length should equal the ArcLength target: got {length}" - ); -} - // Phase 5 of the same plan: an `Ellipse` registers center/focus1/minor- // radius (converted from acadrust's center + major-axis-vector + ratio // parametrization). Only center-based whole-entity constraints are wired @@ -1358,10 +1356,13 @@ fn concentric_constraint_between_an_ellipse_and_a_circle_pulls_centers_together( let circle = add_circle(&mut scene, 10.0, -6.0, 2.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Concentric, - vec![SketchRef::center(ellipse), SketchRef::center(circle)], + vec![ + ParametricRef::center(ellipse), + ParametricRef::center(circle), + ], None, ); scene.bump_entities(&[ @@ -1384,10 +1385,13 @@ fn center_point_constraint_pulls_a_lines_endpoint_onto_an_ellipses_center() { let line = add_line(&mut scene, 10.0, 10.0, 20.0, 20.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::CenterPoint, - vec![SketchRef::point(line, 0), SketchRef::center(ellipse)], + vec![ + ParametricRef::point(line, 0), + ParametricRef::center(ellipse), + ], None, ); @@ -1421,10 +1425,13 @@ fn a_tilted_ellipse_round_trips_through_the_solver_without_drifting() { let circle = add_circle(&mut scene, 2.0, -3.0, 1.0); // already concentric — no solving needed scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Concentric, - vec![SketchRef::center(ellipse), SketchRef::center(circle)], + vec![ + ParametricRef::center(ellipse), + ParametricRef::center(circle), + ], None, ); scene.bump_entities(&[(ellipse, ChangeKind::Modified)]); @@ -1463,10 +1470,13 @@ fn concentric_constraint_does_not_reshape_the_ellipse_when_its_center_actually_m let circle = add_circle(&mut scene, 10.0, 7.0, 1.5); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Concentric, - vec![SketchRef::center(ellipse), SketchRef::center(circle)], + vec![ + ParametricRef::center(ellipse), + ParametricRef::center(circle), + ], None, ); scene.bump_entities(&[ @@ -1500,11 +1510,18 @@ fn fixed_constraint_holds_an_ellipse_in_place_despite_a_connected_edit() { let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (6.0, 0.0), 0.5); let line = add_line(&mut scene, 0.0, 0.0, 5.0, 5.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add(ConstraintKind::Fixed, vec![SketchRef::whole(ellipse)], None); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Fixed, + vec![ParametricRef::whole(ellipse)], + None, + ); set.add( ConstraintKind::CenterPoint, - vec![SketchRef::point(line, 0), SketchRef::center(ellipse)], + vec![ + ParametricRef::point(line, 0), + ParametricRef::center(ellipse), + ], None, ); @@ -1534,10 +1551,10 @@ fn distance_y_constraint_pins_only_the_y_component() { let line = add_line(&mut scene, 0.0, 0.0, 6.0, 8.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::DistanceY, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(DrivingValue::Literal(-3.0)), ); scene.bump_entities(&[(line, ChangeKind::Modified)]); diff --git a/tests/sketch_constraints_xrecord_roundtrip.rs b/tests/sketch_constraints_xrecord_roundtrip.rs deleted file mode 100644 index 435218ac4..000000000 --- a/tests/sketch_constraints_xrecord_roundtrip.rs +++ /dev/null @@ -1,159 +0,0 @@ -// Proves a SketchConstraintSet serialized into an XRecord entry -// block record's extension dictionary, survives a real save/reload through -// both supported file formats byte-for-byte. - -use acadrust::entities::EntityType; -use acadrust::objects::{XRecordEntry, XRecordValue}; -use acadrust::types::{Handle, Vector3}; -use OpenCADStudio::scene::named_parameters::DrivingValue; -use OpenCADStudio::scene::sketch_constraints::{ - ConstraintKind, SketchConstraintSet, SketchRef, SketchScope, -}; -use OpenCADStudio::scene::Scene; - -const RECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS_TEST"; - -fn sample_set(scope: SketchScope) -> SketchConstraintSet { - let mut set = SketchConstraintSet::new(scope); - set.add( - ConstraintKind::Coincident, - vec![ - SketchRef::point(Handle::new(101), 0), - SketchRef::point(Handle::new(102), 1), - ], - None, - ); - set.add( - ConstraintKind::Distance, - vec![SketchRef::whole(Handle::new(103))], - Some(DrivingValue::Literal(25.0)), - ); - set.add( - ConstraintKind::Angle, - vec![ - SketchRef::whole(Handle::new(104)), - SketchRef::whole(Handle::new(105)), - ], - Some(DrivingValue::Literal(45.0)), - ); - set.add( - ConstraintKind::Radius, - vec![SketchRef::whole(Handle::new(106))], - Some(DrivingValue::Literal(3.5)), - ); - set -} - -/// Writes `set` into a fresh XRecord Chunk under `owner`'s extension -/// dictionary, round-trips the whole document through `ext` (`"dxf"` or -/// anything else for DWG), and returns the `SketchConstraintSet` read back -/// out of the reloaded document. -fn roundtrip_through( - scene: &Scene, - owner: Handle, - set: &SketchConstraintSet, - ext: &str, -) -> SketchConstraintSet { - let mut doc = scene.document.clone(); - let bytes = bincode::serialize(set).expect("serialize SketchConstraintSet"); - - doc.ensure_xrecord(owner, RECORD_KEY); - let record = doc - .xrecord_mut(owner, RECORD_KEY) - .expect("xrecord_mut after ensure_xrecord"); - record - .entries - .push(XRecordEntry::new(310, XRecordValue::Chunk(bytes))); - - let saved = OpenCADStudio::io::save_to_bytes(&doc, ext, doc.version) - .unwrap_or_else(|e| panic!("save to {ext} bytes: {e}")); - let reloaded = OpenCADStudio::io::load_bytes(&format!("roundtrip.{ext}"), saved) - .unwrap_or_else(|e| panic!("reload {ext} bytes: {e}")); - - let record = reloaded - .xrecord(owner, RECORD_KEY) - .unwrap_or_else(|| panic!("XRecord {RECORD_KEY:?} missing after {ext} round-trip")); - let chunk = record - .entries - .iter() - .find_map(|entry| match &entry.value { - XRecordValue::Chunk(bytes) => Some(bytes.clone()), - _ => None, - }) - .unwrap_or_else(|| panic!("no Chunk entry survived the {ext} round-trip")); - - bincode::deserialize::(&chunk) - .unwrap_or_else(|e| panic!("deserialize round-tripped {ext} blob: {e}")) -} - -#[test] -fn sketch_constraint_set_survives_a_dxf_roundtrip() { - let scene = Scene::new(); - let owner = scene.document.header.model_space_block_handle; - assert!( - !owner.is_null(), - "a fresh document must already have a model-space block handle" - ); - let original = sample_set(SketchScope::ModelSpace); - - let restored = roundtrip_through(&scene, owner, &original, "dxf"); - - assert_eq!(restored.scope, original.scope); - assert_eq!(restored.constraints.len(), original.constraints.len()); - for (r, o) in restored.constraints.iter().zip(original.constraints.iter()) { - assert_eq!(r.id, o.id); - assert_eq!(r.kind, o.kind); - assert_eq!(r.refs, o.refs); - assert_eq!(r.driving_param, o.driving_param); - assert_eq!(r.enabled, o.enabled); - } -} - -#[test] -fn sketch_constraint_set_survives_a_dwg_roundtrip() { - let scene = Scene::new(); - let owner = scene.document.header.model_space_block_handle; - let original = sample_set(SketchScope::ModelSpace); - - let restored = roundtrip_through(&scene, owner, &original, "dwg"); - - assert_eq!(restored.scope, original.scope); - assert_eq!(restored.constraints.len(), original.constraints.len()); - for (r, o) in restored.constraints.iter().zip(original.constraints.iter()) { - assert_eq!(r.id, o.id); - assert_eq!(r.kind, o.kind); - assert_eq!(r.refs, o.refs); - assert_eq!(r.driving_param, o.driving_param); - } -} - -/// A block-record-scoped set (not model space) — confirms the XRecord -/// extension-dictionary mechanism works for an arbitrary owner handle, not -/// just the one every document already has. -#[test] -fn sketch_constraint_set_survives_roundtrip_when_owned_by_a_block_record() { - let mut scene = Scene::new(); - // A real block definition via the app's own `create_block_from_entities` - // (needs at least one source entity) — a bare, entity-less BlockRecord - // table entry turned out not to survive a DXF round-trip at all (the - // writer prunes it as unreferenced), so this exercises the actual shape - // `SketchScope::Block` will see in practice. - let line_handle = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(1.0, 0.0, 0.0), - ))); - let br_handle = scene - .create_block_from_entities( - &[line_handle], - "OCS_TEST_BLOCK", - &acadrust::types::Transform::identity(), - &acadrust::types::Transform::identity(), - ) - .expect("create a block definition to own the constraint set"); - - let original = sample_set(SketchScope::Block(br_handle)); - let restored = roundtrip_through(&scene, br_handle, &original, "dxf"); - - assert_eq!(restored.scope, SketchScope::Block(br_handle)); - assert_eq!(restored.constraints.len(), original.constraints.len()); -} From 06fcf09ae37f122a111ab8b7bb8ea73fd54c0f1f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 15 Sep 2026 10:43:00 +0300 Subject: [PATCH 15/33] Integrate standard parametric PR fixes --- src/app/command_driver.rs | 103 +++++++++++++++++++++++++++++--- src/app/mod.rs | 2 + src/app/properties.rs | 37 +++++++++++- src/app/update/mod.rs | 4 ++ src/modules/parametric/value.rs | 24 ++++++++ src/scene/model/object.rs | 4 +- src/ui/properties.rs | 21 +++++-- 7 files changed, 179 insertions(+), 16 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0600de34d..cdeea8ed3 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -966,22 +966,20 @@ impl OpenCADStudio { TableCellEditStart::Started } - /// Removes and re-solves the first conflicting constraint in the active scope. - pub(super) fn resolve_one_parametric_conflict(&mut self) { + fn remove_parametric_constraint( + &mut self, + id: crate::scene::parametric_constraints::ConstraintId, + label: &'static str, + ) -> bool { let i = self.active_tab; let scope = self.tabs[i].current_parametric_scope(); let Some(set) = self.tabs[i].scene.parametric_constraint_set(scope) else { - return; - }; - let Some(&(id, _kind)) = set.conflicts.first() else { - return; + return false; }; let Some(constraint) = set.get(id) else { - return; + return false; }; let touched: Vec = constraint.refs.iter().map(|r| r.entity).collect(); - let label = "Remove conflicting constraint"; - let constraints_before = set.clone(); let pending = self.begin_undo(i, label, touched.len(), true); self.tabs[i] @@ -999,6 +997,30 @@ impl OpenCADStudio { if let Some(pd) = pending { self.commit_undo_delta(i, pd); } + self.refresh_properties(); + true + } + + /// Removes and re-solves the first conflicting constraint in the active scope. + pub(super) fn resolve_one_parametric_conflict(&mut self) { + let i = self.active_tab; + let scope = self.tabs[i].current_parametric_scope(); + let Some(id) = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .and_then(|set| set.conflicts.first().map(|(id, _)| *id)) + else { + return; + }; + self.remove_parametric_constraint(id, "Remove conflicting constraint"); + } + + /// Removes one user-selected constraint from the active standard graph scope. + pub(super) fn delete_parametric_constraint( + &mut self, + id: crate::scene::parametric_constraints::ConstraintId, + ) { + self.remove_parametric_constraint(id, "Delete constraint"); } /// Rebuilds the active tab's `Scene::named_parameters` from the @@ -7867,6 +7889,44 @@ mod parametric_constraint_undo_tests { ); } + #[test] + fn deleting_one_parametric_constraint_is_undoable() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0); + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: ConstraintKind::Horizontal, + refs: vec![ParametricRef::whole(line)], + driving_param: None, + label: "Horizontal constraint", + }); + let id = app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints[0] + .id; + + app.delete_parametric_constraint(id); + assert!(app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints + .is_empty()); + + app.undo_steps(1); + assert_eq!( + app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints + .len(), + 1 + ); + } + /// `named_parameters_design.md` stage 4: applying the PARAMETERS editor's /// working buffer both defines the parameter and re-solves whatever /// constraint already references it by name — the entire point of a @@ -7950,6 +8010,31 @@ mod parametric_constraint_undo_tests { ); } + /// The no-selection page must expose the active drawing's named parameters. + #[test] + fn no_selection_properties_exposes_named_parameters() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + app.tabs[app.active_tab] + .scene + .named_parameters_mut() + .set("width", "12") + .unwrap(); + + app.refresh_properties(); + + assert!(app.tabs[app.active_tab] + .properties + .sections + .iter() + .flat_map(|section| §ion.props) + .any(|property| matches!( + &property.value, + crate::scene::model::object::PropValue::ParamRow { name, .. } + if name == "width" + ))); + } + /// End-to-end through the actual `Message` handlers a Properties-panel /// click/keystroke would fire — not `apply_named_parameter_editor_rows` /// directly — covering the whole Parameters-section lifecycle: add, diff --git a/src/app/mod.rs b/src/app/mod.rs index 50a864cf0..7f420ab42 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2902,6 +2902,8 @@ pub enum Message { /// A Constraints-section row was clicked: select every entity in the /// list (replacing the current selection) in the viewport. PropConstraintLinkClick(Vec), + /// Remove one parametric constraint from the active standard graph scope. + PropConstraintDelete(crate::scene::parametric_constraints::ConstraintId), // ── About window ──────────────────────────────────────────────────── AboutOpen, // ── Graphics warning ──────────────────────────────────────────────── diff --git a/src/app/properties.rs b/src/app/properties.rs index 393467b2d..abbcebf7c 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -484,6 +484,35 @@ impl OpenCADStudio { }, ], }, + PropSection { + title: t!("Parameters").into_owned(), + props: { + let mut props: Vec = scene + .named_parameters() + .iter() + .enumerate() + .map(|(index, parameter)| Property { + label: String::new(), + field: "named_parameter", + value: PropValue::ParamRow { + index, + name: parameter.name.clone(), + formula: parameter.source.clone(), + resolved: scene + .named_parameters() + .resolve(¶meter.name) + .map_err(|error| error.to_string()), + }, + }) + .collect(); + props.push(Property { + label: String::new(), + field: "named_parameter_add", + value: PropValue::ParamAddRow, + }); + props + }, + }, ]; ui::PropertiesPanel { title: t!("No selection").into_owned(), @@ -2202,6 +2231,11 @@ impl OpenCADStudio { let scope = self.tabs[i].current_parametric_scope(); let scene = &self.tabs[i].scene; if let Some(set) = scene.parametric_constraint_set(scope) { + let parameters = if set.local_parameters.is_empty() { + scene.named_parameters() + } else { + &set.local_parameters + }; let props: Vec = set .constraints_touching(handle) .map(|c| { @@ -2219,7 +2253,7 @@ impl OpenCADStudio { crate::scene::named_parameters::DrivingValue::Named( name, ), - ) => match scene.named_parameters().resolve(name) { + ) => match parameters.resolve(name) { Ok(v) => format!(" = {name} ({v:.2})"), Err(_) => format!(" = {name} (?)"), }, @@ -2239,6 +2273,7 @@ impl OpenCADStudio { label, field: "parametric_constraint", value: crate::scene::model::object::PropValue::EntityLink { + id: c.id, handles, conflicting, }, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 4d3919596..965ac0cce 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6250,6 +6250,10 @@ impl OpenCADStudio { Message::PropParamCommit { index, field } => self.on_prop_param_commit(index, field), Message::PropParamDelete(index) => self.on_prop_param_delete(index), Message::PropParamAddNew => self.on_prop_param_add_new(), + Message::PropConstraintDelete(id) => { + self.delete_parametric_constraint(id); + Task::none() + } Message::PropConstraintLinkClick(handles) => { let i = self.active_tab; self.tabs[i].scene.deselect_all(); diff --git a/src/modules/parametric/value.rs b/src/modules/parametric/value.rs index de84df66a..94e304f12 100644 --- a/src/modules/parametric/value.rs +++ b/src/modules/parametric/value.rs @@ -331,6 +331,12 @@ impl CadCommand for DistanceConstraintCommand { } fn on_text_input(&mut self, text: &str) -> Option { + // An existing parameter name wins over a mode keyword with the same spelling. + if let Some(value @ DrivingValue::Named(_)) = + parse_driving_value(text, &self.known_param_names) + { + return self.build(value); + } // Mode-switch keywords re-prompt instead of building — consumed // inputs return `Some(NeedPoint)` so the driver doesn't re-offer // the same token a second time (matches e.g. `trim.rs`'s @@ -605,6 +611,24 @@ mod tests { )); } + #[test] + fn a_parameter_named_like_a_mode_keyword_remains_usable() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene); + scene.named_parameters_mut().set("r", "5").unwrap(); + let mut cmd = + DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); + + assert!(matches!( + cmd.on_text_input("R"), + Some(CmdResult::AddParametricConstraint { + kind: ConstraintKind::Radius, + driving_param: Some(DrivingValue::Named(name)), + .. + }) if name == "r" + )); + } + #[test] fn ordinary_distances_reject_nonpositive_and_nonfinite_values() { let mut scene = Scene::new(); diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 85e40c6d7..e2b228d3d 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -62,8 +62,10 @@ pub enum PropValue { /// row for one parametric constraint. Clicking it selects every /// entity in `handles` in the viewport. `conflicting` mirrors /// `ParametricConstraintSet::conflicts`, tinting the row the same danger - /// color the constraint's glyph pill already uses. + /// color the constraint's glyph pill already uses. `id` routes the row's + /// delete action back to that constraint in the active scope. EntityLink { + id: crate::scene::parametric_constraints::ConstraintId, handles: Vec, conflicting: bool, }, diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 507c150d6..59a11dedb 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -865,8 +865,8 @@ impl PropertiesPanel { self.render_hatch_pattern_row(label, current) } PropValue::AttrText { tag, value } => self.render_attr_row(tag, value), - PropValue::EntityLink { handles, conflicting } => { - render_entity_link_row(label, handles.clone(), *conflicting) + PropValue::EntityLink { id, handles, conflicting } => { + render_entity_link_row(label, *id, handles.clone(), *conflicting) } PropValue::ParamRow { index, name, formula, resolved } => { self.render_param_row(*index, name, formula, resolved) @@ -1933,8 +1933,13 @@ fn render_ro_with_tooltip_row<'a>( /// field, so this doesn't use the usual `prop_row_widget` label|value split. /// Clicking it selects every entity in `handles`; a conflicting/redundant /// constraint (mirrors the viewport glyph pill's own color cue) tints red. -fn render_entity_link_row<'a>(label: &'a str, handles: Vec, conflicting: bool) -> Element<'a, Message> { - let btn = button(text(label).size(FONT_SZ).width(Length::Fill)) +fn render_entity_link_row<'a>( + label: &'a str, + id: crate::scene::parametric_constraints::ConstraintId, + handles: Vec, + conflicting: bool, +) -> Element<'a, Message> { + let link = button(text(label).size(FONT_SZ).width(Length::Fill)) .on_press(Message::PropConstraintLinkClick(handles)) .style(move |theme: &Theme, status| { let palette = theme.palette(); @@ -1955,7 +1960,13 @@ fn render_entity_link_row<'a>(label: &'a str, handles: Vec, conflicting: }) .padding([3, 8]) .width(Length::Fill); - container(btn).width(Length::Fill).into() + let delete = button(text("\u{2715}").size(FONT_SZ)) + .on_press(Message::PropConstraintDelete(id)) + .style(button::text) + .padding([2, 6]); + container(row![link, delete].spacing(2).align_y(iced::Center)) + .width(Length::Fill) + .into() } // ── Parameters section: "+ Add parameter" row ────────────────────────────── From 66be530655fa86e504d367e2bec20c63369d0028 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:47:28 +0300 Subject: [PATCH 16/33] Complete automatic constraint behavior --- Cargo.lock | 4 +- Cargo.toml | 4 +- src/app/command_driver.rs | 191 ++++++++++++++++-- src/app/commands/draw.rs | 101 ++++++---- src/app/commands/styleprops.rs | 49 ++++- src/app/mod.rs | 31 +++ src/app/properties.rs | 28 ++- src/app/settings.rs | 122 +++++++++++ src/app/update/command.rs | 40 +++- src/app/update/file.rs | 13 ++ src/app/update/mod.rs | 111 ++++++++++ src/app/update/viewport.rs | 135 ++++++++++++- src/app/view/mod.rs | 12 +- src/app/view/modal.rs | 15 ++ src/command.rs | 2 + src/modules/draw/select.rs | 37 +++- src/modules/parametric/mod.rs | 2 +- src/scene/mod.rs | 58 +++++- src/scene/parametric_constraints.rs | 246 +++++++++++++++++++---- src/scene/parametric_solve.rs | 218 +++++++++++++++++++- src/scene/selection.rs | 1 + src/scene/view/render.rs | 5 +- src/ui/overlay.rs | 27 ++- src/ui/window/auto_constrain_settings.rs | 143 +++++++++++++ src/ui/window/mod.rs | 1 + 25 files changed, 1494 insertions(+), 102 deletions(-) create mode 100644 src/ui/window/auto_constrain_settings.rs diff --git a/Cargo.lock b/Cargo.lock index 7efbae01f..b706764fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -919,7 +919,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b8b664a#b8b664ae993e392fb84689966a44fa9a3f4e38cd" +source = "git+https://github.com/ramox81/cadkernel.git?rev=ffac0b9ba9fc3aec7723a772b08ea84065359c3a#ffac0b9ba9fc3aec7723a772b08ea84065359c3a" dependencies = [ "acadrust", "cavalier_contours", @@ -930,7 +930,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b8b664a#b8b664ae993e392fb84689966a44fa9a3f4e38cd" +source = "git+https://github.com/ramox81/cadkernel.git?rev=ffac0b9ba9fc3aec7723a772b08ea84065359c3a#ffac0b9ba9fc3aec7723a772b08ea84065359c3a" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 688a7c795..a3f180cef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,8 +33,8 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b8b664a", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b8b664a" } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "ffac0b9ba9fc3aec7723a772b08ea84065359c3a", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/ramox81/cadkernel.git", rev = "ffac0b9ba9fc3aec7723a772b08ea84065359c3a" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0600de34d..a689e8852 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1592,6 +1592,92 @@ impl OpenCADStudio { Task::none() } + fn apply_continuous_constraints(&mut self, i: usize, new_handles: &[acadrust::Handle]) { + let supported_creation = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| { + matches!( + command.name(), + "LINE" + | "PLINE" + | "POLYLINE" + | "RECTANG" + | "RECTANGLE" + | "POLYGON" + | "CIRCLE" + | "ARC" + ) + }); + if supported_creation { + self.apply_inferred_constraints(i, new_handles); + } + } + + pub(in crate::app) fn apply_inferred_constraints( + &mut self, + i: usize, + changed_handles: &[acadrust::Handle], + ) { + if !self.constraint_infer || changed_handles.is_empty() { + return; + } + let scope = self.tabs[i].current_parametric_scope(); + let owner = scope.owner_handle(&self.tabs[i].scene.document); + let candidates = self.tabs[i] + .scene + .document + .block_records + .iter() + .find(|record| record.handle == owner) + .map(|record| record.entity_handles.clone()) + .unwrap_or_default(); + let inferred = self.tabs[i].scene.inferred_parametric_constraints( + scope, + &candidates, + &self.auto_constrain_settings, + ); + let inferred: Vec<_> = inferred + .into_iter() + .filter(|(_, refs)| { + refs.iter() + .any(|reference| changed_handles.contains(&reference.entity)) + }) + .collect(); + if inferred.is_empty() { + return; + } + let before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, before); + let mut touched = Vec::new(); + for (kind, refs) in inferred { + touched.extend(refs.iter().map(|reference| reference.entity)); + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(kind, refs, None); + } + touched.sort_unstable_by_key(|handle| handle.value()); + touched.dedup(); + let changes: Vec<_> = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + self.constraint_solve_mode, + ); + } + fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; if let Some(bind) = self.tabs[i] @@ -1613,6 +1699,15 @@ impl OpenCADStudio { .as_ref() .is_some_and(|command| command.preserve_commit_layer()); match result { + CmdResult::OpenAutoConstrainSettings => { + self.auto_constrain_saved = Some(self.auto_constrain_settings.clone()); + self.auto_constrain_selected_row = 0; + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + self.active_modal = Some(super::ModalKind::AutoConstrainSettings); + } CmdResult::NeedPoint => { // ATTEDIT finished its entity pick: hand the chosen block off to // the attribute editor dialog and end the command (open_attribute @@ -1720,6 +1815,9 @@ impl OpenCADStudio { .attach_dimension_association(handle, sources); } } + if let Some(handle) = committed { + self.apply_continuous_constraints(i, &[handle]); + } self.tabs[i].dirty = true; let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { @@ -1760,15 +1858,18 @@ impl OpenCADStudio { } self.refresh_layer_panel(); } + let mut committed = Vec::new(); for entity in entities { - if preserve_commit_style { - let _ = self.commit_entity_handle_preserve_style(entity); + let handle = if preserve_commit_style { + self.commit_entity_handle_preserve_style(entity) } else if preserve_commit_layer { - let _ = self.commit_entity_handle_preserve_layer(entity); + self.commit_entity_handle_preserve_layer(entity) } else { - self.commit_entity(entity); - } + self.commit_entity_handle(entity) + }; + committed.extend(handle); } + self.apply_continuous_constraints(i, &committed); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); @@ -1799,15 +1900,18 @@ impl OpenCADStudio { } self.refresh_layer_panel(); } + let mut committed = Vec::new(); for entity in entities { - if preserve_commit_style { - let _ = self.commit_entity_handle_preserve_style(entity); + let handle = if preserve_commit_style { + self.commit_entity_handle_preserve_style(entity) } else if preserve_commit_layer { - let _ = self.commit_entity_handle_preserve_layer(entity); + self.commit_entity_handle_preserve_layer(entity) } else { - self.commit_entity(entity); - } + self.commit_entity_handle(entity) + }; + committed.extend(handle); } + self.apply_continuous_constraints(i, &committed); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -2063,6 +2167,7 @@ impl OpenCADStudio { // transform_entities — always delta-safe. let pending = self.begin_undo(i, label, handles.len(), true); self.tabs[i].scene.transform_entities(&handles, &transform); + self.apply_inferred_constraints(i, &handles); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -2181,6 +2286,9 @@ impl OpenCADStudio { .attach_dimension_association(handle, sources); } } + if let Some(handle) = committed { + self.apply_continuous_constraints(i, &[handle]); + } self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -2910,6 +3018,7 @@ impl OpenCADStudio { self.tabs[i] .scene .record_undo_parametric_constraints_before(scope, constraints_before); + let retain_size = self.constraint_solve_mode && driving_param.is_none(); self.tabs[i].scene.parametric_constraint_set_mut(scope).add( kind, refs, @@ -2919,7 +3028,11 @@ impl OpenCADStudio { .into_iter() .map(|h| (h, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + retain_size, + ); self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -4467,6 +4580,7 @@ impl OpenCADStudio { let pending = self.begin_undo(i, "STRETCH", handles.len(), !structural); let mut count = 0usize; let mut changed_handles = Vec::new(); + let mut driven_refs = Vec::new(); // Helper: is DXF point (x, y) inside the world-space window? // Drawing plane is world XY (= DXF XY). @@ -4485,6 +4599,54 @@ impl OpenCADStudio { let mut stretched_dims: Vec = Vec::new(); for handle in &handles { let before = self.tabs[i].scene.document.get_entity_arc(*handle); + if let Some(entity) = before.as_deref() { + use crate::scene::parametric_constraints::ParametricRef; + match entity { + acadrust::EntityType::Line(line) => { + if in_win(line.start.x, line.start.y) { + driven_refs.push(ParametricRef::point(*handle, 0)); + } + if in_win(line.end.x, line.end.y) { + driven_refs.push(ParametricRef::point(*handle, 1)); + } + } + acadrust::EntityType::LwPolyline(polyline) => { + if let Some(world) = + crate::entities::curve::lwpolyline_world_xy(polyline) + { + for (index, vertex) in world.vertices.iter().enumerate() { + if in_win(vertex.location.x, vertex.location.y) { + driven_refs.push(ParametricRef::point( + *handle, + index as i32, + )); + } + } + } + } + acadrust::EntityType::Polyline2D(polyline) => { + for (index, vertex) in polyline.vertices.iter().enumerate() { + if in_win(vertex.location.x, vertex.location.y) { + driven_refs.push(ParametricRef::point( + *handle, + index as i32, + )); + } + } + } + acadrust::EntityType::Circle(circle) + if in_win(circle.center.x, circle.center.y) => + { + driven_refs.push(ParametricRef::center(*handle)); + } + acadrust::EntityType::Arc(arc) + if in_win(arc.center.x, arc.center.y) => + { + driven_refs.push(ParametricRef::center(*handle)); + } + _ => {} + } + } let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*handle) else { continue; }; @@ -4707,7 +4869,12 @@ impl OpenCADStudio { .iter() .map(|&handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &driven_refs, + self.constraint_solve_mode && !driven_refs.is_empty(), + ); + self.apply_inferred_constraints(i, &changed_handles); } self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index e45c8c1a4..7ef3f7b36 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1113,55 +1113,86 @@ impl OpenCADStudio { } } + "CONSTRAINTSETTINGS" => { + self.auto_constrain_saved = Some(self.auto_constrain_settings.clone()); + self.auto_constrain_selected_row = 0; + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + self.active_modal = Some(crate::app::ModalKind::AutoConstrainSettings); + } + "AUTOCONSTRAIN" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; - let sel = SelectObjectsCommand::new(cmd); + let sel = SelectObjectsCommand::auto_constrain(cmd); self.command_line.push_info(&sel.prompt()); self.tabs[i].active_cmd = Some(Box::new(sel)); } else { let scope = self.tabs[i].current_parametric_scope(); let inferred = self.tabs[i] .scene - .inferred_parametric_constraints(scope, &handles); + .inferred_parametric_constraints( + scope, + &handles, + &self.auto_constrain_settings, + ); if inferred.is_empty() { - self.command_line - .push_output("No supported geometric relations were found."); - return None; - } - let before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new( - scope, + self.command_line.push_output( + format!( + "0 constraint(s) applied to {} object(s).", + handles.len() ) - }); - let pending = self.begin_undo(i, "Auto constrain", handles.len(), true); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, before); - let count = inferred.len(); - for (kind, refs) in inferred { + .as_str(), + ); + self.tabs[i].scene.deselect_all(); + self.refresh_selected_grips(); + } else { + let before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new( + scope, + ) + }); + let pending = self.begin_undo(i, "Auto constrain", handles.len(), true); self.tabs[i] .scene - .parametric_constraint_set_mut(scope) - .add(kind, refs, None); - } - let changes: Vec<_> = handles - .iter() - .copied() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities(&changes); - self.tabs[i].dirty = true; - self.refresh_properties(); - self.command_line - .push_output(format!("{} constraint(s) applied.", count).as_str()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + .record_undo_parametric_constraints_before(scope, before); + let count = inferred.len(); + for (kind, refs) in inferred { + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(kind, refs, None); + } + let changes: Vec<_> = handles + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + self.constraint_solve_mode, + ); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + format!( + "{} constraint(s) applied to {} object(s).", + count, + handles.len() + ) + .as_str(), + ); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } } diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 9fed425e9..751d2e5d2 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1046,6 +1046,9 @@ impl OpenCADStudio { | "GRIPHOT" | "GRIPHOVER" | "GRIPOBJLIMIT" + | "CONSTRAINTSOLVEMODE" + | "CONSTRAINTINFER" + | "CONSTRAINTBARDISPLAY" ) => { return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i); @@ -1068,7 +1071,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), + crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), ); } else { if name == "CETRANSPARENCY" { @@ -1262,6 +1265,50 @@ impl OpenCADStudio { } return Some(self.finish_dispatch(cmd)); } + if matches!( + name.as_str(), + "CONSTRAINTSOLVEMODE" | "CONSTRAINTINFER" | "CONSTRAINTBARDISPLAY" + ) { + let current = match name.as_str() { + "CONSTRAINTSOLVEMODE" => i16::from(self.constraint_solve_mode), + "CONSTRAINTINFER" => i16::from(self.constraint_infer), + "CONSTRAINTBARDISPLAY" => self.constraint_bar_display, + _ => unreachable!(), + }; + let maximum = if name == "CONSTRAINTBARDISPLAY" { 3 } else { 1 }; + if let Some(value) = &value { + match value + .parse::() + .ok() + .filter(|value| (0..=maximum).contains(value)) + { + Some(mode) => { + match name.as_str() { + "CONSTRAINTSOLVEMODE" => { + self.constraint_solve_mode = mode != 0 + } + "CONSTRAINTINFER" => self.constraint_infer = mode != 0, + "CONSTRAINTBARDISPLAY" => { + self.constraint_bar_display = mode + } + _ => unreachable!(), + } + self.persist_settings_if_changed(); + self.command_line + .push_output(&crate::tf!("{name} = {mode}")); + } + None => self.command_line.push_error(&crate::tf!( + "{name}: expected an integer from 0 to {maximum}." + )), + } + } else { + self.command_line.push_output(crate::tf!( + "Enter new value for {name} <{current}>:" + ).as_ref()); + self.pending_setvar = Some(name.clone()); + } + return Some(self.finish_dispatch(cmd)); + } if matches!(name.as_str(), "DONUTID" | "DONUTOD") { let current = if name == "DONUTID" { crate::modules::draw::defaults::get_donut_inner_diameter() diff --git a/src/app/mod.rs b/src/app/mod.rs index 50a864cf0..af96e2ac4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -556,6 +556,14 @@ pub(super) struct OpenCADStudio { /// glyph plus a driven value or named-parameter name. When false, every /// pill shows only the glyph. pub show_constraint_values: bool, + pub auto_constrain_settings: settings::AutoConstrainSettings, + auto_constrain_saved: Option, + auto_constrain_selected_row: usize, + auto_constrain_distance_input: String, + auto_constrain_angle_input: String, + pub constraint_solve_mode: bool, + pub constraint_infer: bool, + pub constraint_bar_display: i16, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); /// 0 disables autosave. pub savetime_min: i32, @@ -1722,6 +1730,7 @@ pub enum ModalKind { DrawingUnits, GeometricTolerance, DraftingSettings, + AutoConstrainSettings, LayerStateEditor, Plot, PrintAll, @@ -2640,6 +2649,20 @@ pub enum Message { DraftingSettingsClose, DraftingSettingsCloseDiscard, DraftingSettingsCloseKeep, + AutoConstrainSelectRow(usize), + AutoConstrainToggleKind(settings::AutoConstraintKind), + AutoConstrainMoveUp, + AutoConstrainMoveDown, + AutoConstrainSelectAll, + AutoConstrainClearAll, + AutoConstrainReset, + AutoConstrainToggleTangentPoint, + AutoConstrainTogglePerpendicularIntersection, + AutoConstrainDistanceChanged(String), + AutoConstrainAngleChanged(String), + AutoConstrainApply, + AutoConstrainOk, + AutoConstrainCancel, /// Toggle a ribbon dropdown open/closed. ToggleRibbonDropdown(String), /// Toggle a collapsed ribbon panel's flyout open/closed (by panel title). @@ -3631,6 +3654,14 @@ impl OpenCADStudio { backup_on_save: true, file_assoc_enabled: true, show_constraint_values: true, + auto_constrain_settings: settings::AutoConstrainSettings::default(), + auto_constrain_saved: None, + auto_constrain_selected_row: 0, + auto_constrain_distance_input: "0.05".to_string(), + auto_constrain_angle_input: "1".to_string(), + constraint_solve_mode: true, + constraint_infer: false, + constraint_bar_display: 3, savetime_min: 10, default_bg_color: None, default_paper_bg_color: None, diff --git a/src/app/properties.rs b/src/app/properties.rs index 393467b2d..cb3b846be 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2772,6 +2772,25 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} } pub(super) fn invalidate_property_targets(&mut self, i: usize, handles: &[Handle]) { + self.invalidate_property_targets_with_driven(i, handles, &[]); + } + + pub(super) fn invalidate_property_targets_with_driven( + &mut self, + i: usize, + handles: &[Handle], + driven_refs: &[crate::scene::parametric_constraints::ParametricRef], + ) { + self.invalidate_property_targets_with_originals(i, handles, driven_refs, &[]); + } + + pub(super) fn invalidate_property_targets_with_originals( + &mut self, + i: usize, + handles: &[Handle], + driven_refs: &[crate::scene::parametric_constraints::ParametricRef], + retained_originals: &[(Handle, acadrust::EntityType)], + ) { let mut context_object_changed = false; for &handle in handles { // A dimension is drawn from the block holding its picture, and that @@ -2801,7 +2820,14 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} .iter() .map(|&handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i] + .scene + .bump_entities_with_parametric_originals( + &changes, + driven_refs, + self.constraint_solve_mode && !driven_refs.is_empty(), + retained_originals, + ); } /// Apply a single-property edit to every handle in `handles`, recording the diff --git a/src/app/settings.rs b/src/app/settings.rs index 4607b72d6..12cf09c9d 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -11,6 +11,103 @@ use crate::snap::SnapType; use serde::{Deserialize, Serialize}; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AutoConstraintKind { + Coincident, + Collinear, + Parallel, + Perpendicular, + Tangent, + Concentric, + Horizontal, + Vertical, + Equal, +} + +impl AutoConstraintKind { + pub const ALL: [Self; 9] = [ + Self::Coincident, + Self::Collinear, + Self::Parallel, + Self::Perpendicular, + Self::Tangent, + Self::Concentric, + Self::Horizontal, + Self::Vertical, + Self::Equal, + ]; + + pub const fn label(self) -> &'static str { + match self { + Self::Coincident => "Coincident", + Self::Collinear => "Collinear", + Self::Parallel => "Parallel", + Self::Perpendicular => "Perpendicular", + Self::Tangent => "Tangent", + Self::Concentric => "Concentric", + Self::Horizontal => "Horizontal", + Self::Vertical => "Vertical", + Self::Equal => "Equal", + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AutoConstrainSettings { + pub priority: Vec, + pub enabled: Vec, + pub tangent_must_share_point: bool, + pub perpendicular_must_intersect: bool, + pub distance_tolerance: f64, + pub angle_tolerance_deg: f64, +} + +impl Default for AutoConstrainSettings { + fn default() -> Self { + Self { + priority: AutoConstraintKind::ALL.to_vec(), + enabled: AutoConstraintKind::ALL.to_vec(), + tangent_must_share_point: true, + perpendicular_must_intersect: true, + distance_tolerance: 0.05, + angle_tolerance_deg: 1.0, + } + } +} + +impl AutoConstrainSettings { + pub fn sanitize(&mut self) { + let mut priority = Vec::with_capacity(AutoConstraintKind::ALL.len()); + for kind in self + .priority + .iter() + .copied() + .chain(AutoConstraintKind::ALL) + { + if !priority.contains(&kind) { + priority.push(kind); + } + } + self.priority = priority; + self.enabled + .retain(|kind| AutoConstraintKind::ALL.contains(kind)); + self.enabled.sort_by_key(|kind| { + self.priority + .iter() + .position(|candidate| candidate == kind) + .unwrap_or(usize::MAX) + }); + self.enabled.dedup(); + if !self.distance_tolerance.is_finite() || self.distance_tolerance < 0.0 { + self.distance_tolerance = 0.05; + } + if !self.angle_tolerance_deg.is_finite() || self.angle_tolerance_deg < 0.0 { + self.angle_tolerance_deg = 1.0; + } + } +} + /// Cursor shown over the drawing viewport. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum CursorType { @@ -289,6 +386,19 @@ pub struct UserSettings { /// canvas detail on a dense sketch. #[serde(default = "default_show_constraint_values")] pub show_constraint_values: bool, + /// Inference types, priority, intersection rules, and tolerances used by + /// the Auto Constrain command. + #[serde(default)] + pub auto_constrain: AutoConstrainSettings, + /// Keep existing geometry size while solving after a constraint edit. + #[serde(default = "default_constraint_solve_mode")] + pub constraint_solve_mode: bool, + /// Apply eligible geometric constraints while creating geometry. + #[serde(default)] + pub constraint_infer: bool, + /// Constraint bar display bit mask: 1 after applying, 2 on selection. + #[serde(default = "default_constraint_bar_display")] + pub constraint_bar_display: i16, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command). /// 0 disables autosave. pub savetime_min: i32, @@ -359,6 +469,14 @@ fn default_show_constraint_values() -> bool { true } +fn default_constraint_solve_mode() -> bool { + true +} + +fn default_constraint_bar_display() -> i16 { + 3 +} + fn deserialize_clipromptlines<'de, D>(de: D) -> Result where D: serde::Deserializer<'de>, @@ -415,6 +533,10 @@ impl Default for UserSettings { backup_on_save: true, file_assoc_enabled: true, show_constraint_values: true, + auto_constrain: AutoConstrainSettings::default(), + constraint_solve_mode: true, + constraint_infer: false, + constraint_bar_display: 3, savetime_min: 10, default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), pick_add: true, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 4dd845b72..fd58e70d5 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -3247,6 +3247,29 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].properties.active_field = None; let handles = self.property_target_handles(i); if !handles.is_empty() { + let driven_marker = match field { + "start_x" | "start_y" | "start_z" => Some(0), + "end_x" | "end_y" | "end_z" => Some(1), + "center_x" | "center_y" | "center_z" => Some(-3), + _ => None, + }; + let retained_originals: Vec<_> = if self.constraint_solve_mode + && driven_marker.is_some() + { + handles + .iter() + .filter_map(|handle| { + self.tabs[i] + .scene + .document + .get_entity(*handle) + .cloned() + .map(|entity| (*handle, entity)) + }) + .collect() + } else { + Vec::new() + }; let evaluates_expression = self.tabs[i] .properties .sections @@ -3563,7 +3586,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } - self.invalidate_property_targets(i, &handles); + let driven_refs: Vec<_> = driven_marker + .into_iter() + .flat_map(|marker| { + handles.iter().copied().map(move |handle| { + crate::scene::parametric_constraints::ParametricRef::point( + handle, marker, + ) + }) + }) + .collect(); + self.invalidate_property_targets_with_originals( + i, + &handles, + &driven_refs, + &retained_originals, + ); self.tabs[i].dirty = true; self.refresh_properties(); } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index ac9b254cf..1d7301d63 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -429,6 +429,10 @@ impl OpenCADStudio { backup_on_save: self.backup_on_save, file_assoc_enabled: self.file_assoc_enabled, show_constraint_values: self.show_constraint_values, + auto_constrain: self.auto_constrain_settings.clone(), + constraint_solve_mode: self.constraint_solve_mode, + constraint_infer: self.constraint_infer, + constraint_bar_display: self.constraint_bar_display, savetime_min: self.savetime_min, default_save_format: self.default_save_format.clone(), pick_add: self.pick_add, @@ -510,6 +514,15 @@ impl OpenCADStudio { self.backup_on_save = s.backup_on_save; self.file_assoc_enabled = s.file_assoc_enabled; self.show_constraint_values = s.show_constraint_values; + self.auto_constrain_settings = s.auto_constrain.clone(); + self.auto_constrain_settings.sanitize(); + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + self.constraint_solve_mode = s.constraint_solve_mode; + self.constraint_infer = s.constraint_infer; + self.constraint_bar_display = s.constraint_bar_display.clamp(0, 3); self.savetime_min = s.savetime_min; self.default_save_format = crate::io::canonical_save_format(&s.default_save_format).to_string(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 4d3919596..6354847fb 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4289,6 +4289,112 @@ impl OpenCADStudio { self.drafting_settings_close_confirm = false; Task::none() } + Message::AutoConstrainSelectRow(index) => { + if index < self.auto_constrain_settings.priority.len() { + self.auto_constrain_selected_row = index; + } + Task::none() + } + Message::AutoConstrainToggleKind(kind) => { + if let Some(index) = self + .auto_constrain_settings + .enabled + .iter() + .position(|candidate| *candidate == kind) + { + self.auto_constrain_settings.enabled.remove(index); + } else { + self.auto_constrain_settings.enabled.push(kind); + } + Task::none() + } + Message::AutoConstrainMoveUp => { + let index = self.auto_constrain_selected_row; + if index > 0 && index < self.auto_constrain_settings.priority.len() { + self.auto_constrain_settings.priority.swap(index, index - 1); + self.auto_constrain_selected_row -= 1; + } + Task::none() + } + Message::AutoConstrainMoveDown => { + let index = self.auto_constrain_selected_row; + if index + 1 < self.auto_constrain_settings.priority.len() { + self.auto_constrain_settings.priority.swap(index, index + 1); + self.auto_constrain_selected_row += 1; + } + Task::none() + } + Message::AutoConstrainSelectAll => { + self.auto_constrain_settings.enabled = + super::settings::AutoConstraintKind::ALL.to_vec(); + Task::none() + } + Message::AutoConstrainClearAll => { + self.auto_constrain_settings.enabled.clear(); + Task::none() + } + Message::AutoConstrainReset => { + self.auto_constrain_settings = + super::settings::AutoConstrainSettings::default(); + self.auto_constrain_selected_row = 0; + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + Task::none() + } + Message::AutoConstrainToggleTangentPoint => { + self.auto_constrain_settings.tangent_must_share_point = + !self.auto_constrain_settings.tangent_must_share_point; + Task::none() + } + Message::AutoConstrainTogglePerpendicularIntersection => { + self.auto_constrain_settings.perpendicular_must_intersect = + !self.auto_constrain_settings.perpendicular_must_intersect; + Task::none() + } + Message::AutoConstrainDistanceChanged(value) => { + self.auto_constrain_distance_input = value; + Task::none() + } + Message::AutoConstrainAngleChanged(value) => { + self.auto_constrain_angle_input = value; + Task::none() + } + action @ (Message::AutoConstrainApply | Message::AutoConstrainOk) => { + let distance = self.auto_constrain_distance_input.trim().parse::(); + let angle = self.auto_constrain_angle_input.trim().parse::(); + match (distance, angle) { + (Ok(distance), Ok(angle)) + if distance.is_finite() + && distance >= 0.0 + && angle.is_finite() + && angle >= 0.0 => + { + self.auto_constrain_settings.distance_tolerance = distance; + self.auto_constrain_settings.angle_tolerance_deg = angle; + self.auto_constrain_settings.sanitize(); + self.auto_constrain_saved = + Some(self.auto_constrain_settings.clone()); + self.persist_settings_if_changed(); + if matches!(action, Message::AutoConstrainOk) { + self.close_active_modal(); + } + } + _ => self.command_line.push_error( + crate::t!("Distance and angle tolerances must be non-negative numbers.") + .as_ref(), + ), + } + Task::none() + } + Message::AutoConstrainCancel => { + if let Some(saved) = self.auto_constrain_saved.take() { + self.auto_constrain_settings = saved; + } + self.close_active_modal(); + Task::none() + } // ── Ribbon dropdowns ────────────────────────────────────────── Message::ToggleRibbonDropdown(id) => { @@ -6847,6 +6953,11 @@ impl OpenCADStudio { return Task::none(); } self.drafting_settings_close_confirm = false; + if self.active_modal == Some(super::ModalKind::AutoConstrainSettings) { + if let Some(saved) = self.auto_constrain_saved.take() { + self.auto_constrain_settings = saved; + } + } let resume_open_queue = self.active_modal == Some(super::ModalKind::Recovery); self.close_active_modal(); if resume_open_queue { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index ae3372607..11d5bf180 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -933,6 +933,13 @@ impl OpenCADStudio { return Task::none(); } let i = self.active_tab; + let constraint_hover = self + .constraint_glyph_under(i, p) + .map(|(_, handles)| handles) + .unwrap_or_default(); + self.tabs[i] + .scene + .set_constraint_hover_highlights(&constraint_hover); // Modifier-driven selection must be known before cursor_plane/axis and // drafting constraints are read, not merely before the final callback. if let Some(command) = self.tabs[i].active_cmd.as_mut() { @@ -1737,11 +1744,31 @@ impl OpenCADStudio { ); } } + let driven_refs: Vec<_> = grip + .targets + .iter() + .filter_map(|target| { + let entity = self.tabs[i] + .scene + .document + .get_entity(target.handle)?; + crate::scene::parametric_constraints::driven_ref_for_grip( + entity, + target.handle, + target.grip_id, + ) + }) + .collect(); // Re-solve constrained neighbors on each drag frame and include // their original state in the gesture's undo record. let solved_by_constraints = self.tabs[i] .scene - .solve_parametric_constraints_preview(&edited_handles); + .solve_parametric_constraints_preview( + &edited_handles, + &driven_refs, + self.constraint_solve_mode && !driven_refs.is_empty(), + &self.grip_originals, + ); for (handle, _) in &solved_by_constraints { let handle = *handle; if !self.grip_preview_handles.contains(&handle) { @@ -2637,6 +2664,7 @@ impl OpenCADStudio { // viewport so it doesn't stick while the mouse is over the // ribbon / panels. self.tabs[i].scene.set_hover_highlight(None); + self.tabs[i].scene.set_constraint_hover_highlights(&[]); // Don't touch `context_menu` here. ViewportExit also fires // when an upper overlay (the right-click menu panel) takes // the cursor, so clearing the menu state on every exit @@ -2949,6 +2977,94 @@ impl OpenCADStudio { Task::none() } + fn constraint_glyph_under( + &self, + i: usize, + cursor: iced::Point, + ) -> Option<( + crate::scene::parametric_constraints::ConstraintKind, + Vec, + )> { + if self.tabs[i].scene.current_layout != "Model" { + return None; + } + let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; + let scope = self.tabs[i].current_parametric_scope(); + let set = self.tabs[i].scene.parametric_constraint_set(scope)?; + let edit_frame = self.tabs[i].scene.viewport_edit_frame((vw, vh)); + let bounds = match &edit_frame { + Some((_, full)) => *full, + None => self.tabs[i].scene.active_model_tile_bounds(vw, vh), + }; + let (view_rot, eye) = if let Some((camera, _)) = &edit_frame { + (camera.view_proj_rte(bounds), camera.eye()) + } else { + let camera = self.tabs[i].scene.camera.borrow(); + (camera.view_proj_rte(bounds), camera.eye()) + }; + let mut ids = Vec::new(); + let mut glyphs = Vec::new(); + for constraint in set.constraints.iter().filter(|constraint| { + let selected = constraint.refs.iter().any(|reference| { + self.tabs[i].scene.selected.contains(&reference.entity) + }); + constraint.enabled + && self.tabs[i].scene.should_display_parametric_constraint( + scope, + constraint.id, + selected, + self.constraint_bar_display, + ) + }) { + let Some((anchor, outward)) = + crate::scene::parametric_constraints::glyph_placement( + &self.tabs[i].scene.document, + constraint, + ) else { + continue; + }; + let Some(screen) = crate::scene::pick::grip::project_rte( + glam::DVec3::new(anchor.x, anchor.y, anchor.z), + view_rot, + eye, + bounds, + ) else { + continue; + }; + let Some(outward_screen) = crate::scene::pick::grip::project_rte( + glam::DVec3::new( + anchor.x + outward.x, + anchor.y + outward.y, + anchor.z + outward.z, + ), + view_rot, + eye, + bounds, + ) else { + continue; + }; + let direction = (outward_screen - screen).normalize_or(glam::Vec2::NEG_Y); + let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y); + let label = if self.show_constraint_values { + crate::scene::parametric_constraints::glyph_label(constraint) + } else { + constraint.kind.glyph_symbol().to_string() + }; + let conflict = set + .conflicts + .iter() + .any(|(id, _)| *id == constraint.id); + ids.push(constraint.id); + glyphs.push((point, direction.to_array(), label, conflict)); + } + let index = crate::ui::overlay::constraint_glyph_hit(&glyphs, cursor)?; + let constraint = set.get(ids[index])?; + let mut handles: Vec<_> = constraint.refs.iter().map(|r| r.entity).collect(); + handles.sort_unstable_by_key(|handle| handle.value()); + handles.dedup(); + Some((constraint.kind, handles)) + } + pub(super) fn on_viewport_left_press(&mut self) -> Task { let i = self.active_tab; if let Some(command) = self.tabs[i].active_cmd.as_mut() { @@ -3004,6 +3120,23 @@ impl OpenCADStudio { }; let (vw, vh) = vp_size; + if self.tabs[i].active_cmd.is_none() { + if let Some((kind, handles)) = self.constraint_glyph_under(i, p) { + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entities(&handles); + self.refresh_selected_grips(); + self.refresh_properties(); + self.command_line + .push_info(&format!("{kind:?} constraint selected.")); + self.tabs[i] + .scene + .selection + .borrow_mut() + .clear_left_selection_gesture(); + return Task::none(); + } + } + // An engaged grip owns the next left press (click-move-click placement // or the release of a press-drag). Do not let the same press arm the // normal box/lasso state or trigger another viewport control; the diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index bd48e6260..d4c133128 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -835,7 +835,17 @@ bg={bg_ms:.1}ms n={view_count}" set.constraints .iter() .filter(|c| c.enabled) - .filter(|c| tab.scene.is_parametric_constraint_visible(scope, c.id)) + .filter(|c| { + let selected = c.refs.iter().any(|reference| { + tab.scene.selected.contains(&reference.entity) + }); + tab.scene.should_display_parametric_constraint( + scope, + c.id, + selected, + self.constraint_bar_display, + ) + }) .filter_map(|c| { let (anchor, outward) = crate::scene::parametric_constraints::glyph_placement( diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 218fe3e4d..2291d8654 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -25,6 +25,7 @@ impl OpenCADStudio { Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(), Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(), Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(), + Some(K::AutoConstrainSettings) => crate::t!("Constraint Settings").into_owned(), Some(K::LayerStateEditor) => crate::tr!("modal", "edit-layer-state"), Some(K::Plot) => crate::tr!("modal", "plot"), Some(K::PrintAll) => t!("Print All").into_owned(), @@ -307,6 +308,20 @@ impl OpenCADStudio { }, ) } + super::super::ModalKind::AutoConstrainSettings => sized_flow( + ex, + 620, + 610, + |flow| { + crate::ui::window::auto_constrain_settings::view_window( + &self.auto_constrain_settings, + self.auto_constrain_selected_row, + &self.auto_constrain_distance_input, + &self.auto_constrain_angle_input, + flow, + ) + }, + ), super::super::ModalKind::FindReplace => automatic_flow(ex, |flow| { crate::ui::window::find_replace::view_window( &self.find_replace.search, diff --git a/src/command.rs b/src/command.rs index b4e9da1df..bdc482e3f 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1466,6 +1466,8 @@ pub enum CmdResult { /// Undo-history label, e.g. `"Horizontal constraint"`. label: &'static str, }, + /// Opens the Auto Constrain settings dialog from the selection prompt. + OpenAutoConstrainSettings, /// Adds a Coincident constraint between the sub-entity points nearest /// the two picked positions. The host resolves the raw points because /// `CadCommand` has no document access. diff --git a/src/modules/draw/select.rs b/src/modules/draw/select.rs index 8116fbc07..d695ce6d7 100644 --- a/src/modules/draw/select.rs +++ b/src/modules/draw/select.rs @@ -34,6 +34,8 @@ pub struct SelectObjectsCommand { /// Whether the polygon being picked takes what it merely touches. Ignored /// by a fence, which has no inside to speak of. pick_crossing: bool, + /// Add the command-specific Settings keyword used by Auto Constrain. + auto_constrain_settings: bool, } impl SelectObjectsCommand { @@ -47,6 +49,7 @@ impl SelectObjectsCommand { show_options: true, pick: None, pick_crossing: true, + auto_constrain_settings: false, } } @@ -61,6 +64,7 @@ impl SelectObjectsCommand { show_options: false, pick: None, pick_crossing: true, + auto_constrain_settings: false, } } @@ -75,8 +79,15 @@ impl SelectObjectsCommand { show_options: false, pick: None, pick_crossing: true, + auto_constrain_settings: false, } } + + pub fn auto_constrain(pending_cmd: &str) -> Self { + let mut command = Self::new(pending_cmd); + command.auto_constrain_settings = true; + command + } } impl CadCommand for SelectObjectsCommand { @@ -101,6 +112,14 @@ impl CadCommand for SelectObjectsCommand { .into_owned(); } if self.commit_on_enter && !self.handles.is_empty() { + if self.auto_constrain_settings { + return t!( + "%{cmd} Select objects or [Settings] (%{count} selected, Enter to apply):", + cmd = self.prompt_cmd, + count = self.handles.len() + ) + .into_owned(); + } t!( "%{cmd} Select objects (%{count} selected, Enter to apply):", cmd = self.prompt_cmd, @@ -108,6 +127,13 @@ impl CadCommand for SelectObjectsCommand { ) .into_owned() } else { + if self.auto_constrain_settings { + return t!( + "%{cmd} Select objects or [Settings]:", + cmd = self.prompt_cmd + ) + .into_owned(); + } t!("%{cmd} Select objects:", cmd = self.prompt_cmd).into_owned() } } @@ -130,7 +156,10 @@ impl CadCommand for SelectObjectsCommand { // the on-screen keyboard-less case needs. fn options(&self) -> Vec { if self.commit_on_enter && self.show_options { - vec![ + if self.auto_constrain_settings { + return vec![CmdOption::new(t!("Settings").as_ref(), "S")]; + } + let options = vec![ CmdOption::new(t!("Window").as_ref(), "W"), CmdOption::new(t!("Crossing").as_ref(), "C"), CmdOption::new(t!("Fence").as_ref(), "F"), @@ -141,7 +170,8 @@ impl CadCommand for SelectObjectsCommand { CmdOption::new(t!("Remove").as_ref(), "R"), CmdOption::new(t!("Previous").as_ref(), "P"), CmdOption::new(t!("Last").as_ref(), "L"), - ] + ]; + options } else { Vec::new() } @@ -181,6 +211,9 @@ impl CadCommand for SelectObjectsCommand { return None; } match text.trim().to_uppercase().as_str() { + "S" | "SETTINGS" if self.auto_constrain_settings => { + Some(CmdResult::OpenAutoConstrainSettings) + } "F" | "FENCE" => { self.pick = Some(FencePick::fence()); Some(CmdResult::NeedPoint) diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 41efdbf9e..9539651ee 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -128,7 +128,7 @@ impl CadModule for ParametricModule { inventory::submit!(crate::command::CommandRegistration { names: &[ - "AUTOCONSTRAIN", "SMOOTHCONSTRAINT", "GCSHOW", "GCHIDE", "GCRESET", + "AUTOCONSTRAIN", "CONSTRAINTSETTINGS", "SMOOTHCONSTRAINT", "GCSHOW", "GCHIDE", "GCRESET", "GCSHOWALL", "GCHIDEALL", "DCSHOW", "DCHIDE", "DCSHOWALL", "DCHIDEALL", "DCCONVERT", "DELCONSTRAINT", ] diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 928cc2844..b8296048c 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1676,6 +1676,8 @@ pub struct Scene { /// Entity drawn with the selection-highlight colour without being part /// of the real selection — used to preview a row in the cycling list box. pub hover_highlight: Option, + /// All entities related to the constraint indicator currently under the cursor. + constraint_hover_highlights: HashSet, /// Color used to draw the selection highlight overlay wires (SELECTIONEFFECTCOLOR). pub selection_color: [f32; 4], /// Whether selection visual effect (glow/highlight) is enabled (SELECTIONEFFECT). @@ -1956,6 +1958,11 @@ pub struct Scene { parametric_constraints::ParametricScope, parametric_constraints::ConstraintId, )>, + /// Constraint glyphs explicitly shown through the constraint-bar commands. + shown_parametric_constraints: HashSet<( + parametric_constraints::ParametricScope, + parametric_constraints::ConstraintId, + )>, /// Document-wide named-parameter and expression table decoded from /// standard associative variables. pub(crate) named_parameters: named_parameters::ParameterTable, @@ -2199,6 +2206,7 @@ impl Scene { command_preview_hidden: HashSet::default(), refedit_keep: None, hover_highlight: None, + constraint_hover_highlights: HashSet::default(), selection_color: crate::scene::model::wire_model::WireModel::SELECTED, selection_effect: true, transparency_display: true, @@ -2273,6 +2281,7 @@ impl Scene { associative_hatch_source_cache: RefCell::new(None), parametric_constraints: Vec::new(), hidden_parametric_constraints: HashSet::default(), + shown_parametric_constraints: HashSet::default(), named_parameters: named_parameters::ParameterTable::new(), has_associative_centers: std::cell::Cell::new(None), block_defn_cache: RefCell::new(HashMap::default()), @@ -2832,6 +2841,33 @@ impl Scene { } pub fn bump_entities(&mut self, changes: &[(Handle, ChangeKind)]) { + self.bump_entities_with_parametric_policy(changes, &[], false); + } + + pub fn bump_entities_with_parametric_driven( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + ) { + self.bump_entities_with_parametric_policy(changes, driven_refs, false); + } + + pub fn bump_entities_with_parametric_policy( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + retain_size: bool, + ) { + self.bump_entities_with_parametric_originals(changes, driven_refs, retain_size, &[]); + } + + pub fn bump_entities_with_parametric_originals( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], + ) { if changes.iter().any(|(handle, kind)| { matches!(kind, ChangeKind::Removed) || self @@ -2865,7 +2901,12 @@ impl Scene { } } if !self.parametric_constraints.is_empty() { - for change in self.refresh_parametric_constraints(&changes) { + for change in self.refresh_parametric_constraints_with_originals( + &changes, + driven_refs, + retain_size, + retained_originals, + ) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } @@ -5069,9 +5110,20 @@ impl Scene { /// remains the picked member for hit-test/UI bookkeeping; rendering expands /// it to the same selectable group that a click would select. pub fn hover_highlight_handles(&self) -> HashSet { - self.hover_highlight + let mut handles = self + .hover_highlight .map(|handle| self.handles_expanded_for_selectable_groups(&[handle])) - .unwrap_or_default() + .unwrap_or_default(); + handles.extend(self.constraint_hover_highlights.iter().copied()); + handles + } + + pub fn set_constraint_hover_highlights(&mut self, handles: &[Handle]) { + let desired: HashSet<_> = handles.iter().copied().collect(); + if desired != self.constraint_hover_highlights { + self.constraint_hover_highlights = desired; + self.bump_selection(); + } } /// Keep the current selection visible and temporarily filter every other diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index fe55c5dc4..c8866ead4 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -83,6 +83,45 @@ impl ParametricRef { } } +/// Point reference driven by a native entity grip. Segment midpoint and +/// scalar radius grips deliberately return None because they do not identify +/// one stored point parameter. +pub(crate) fn driven_ref_for_grip( + entity: &acadrust::EntityType, + handle: Handle, + grip_id: usize, +) -> Option { + match entity { + acadrust::EntityType::Line(_) if grip_id <= 1 => { + Some(ParametricRef::point(handle, grip_id as i32)) + } + acadrust::EntityType::LwPolyline(polyline) if grip_id < polyline.vertices.len() => { + Some(ParametricRef::point(handle, grip_id as i32)) + } + acadrust::EntityType::Polyline2D(polyline) if grip_id < polyline.vertices.len() => { + Some(ParametricRef::point(handle, grip_id as i32)) + } + acadrust::EntityType::Arc(_) => match grip_id { + 0 => Some(ParametricRef::center(handle)), + 1 => Some(ParametricRef::point(handle, 0)), + 3 => Some(ParametricRef::point(handle, 1)), + _ => None, + }, + acadrust::EntityType::Circle(_) if grip_id == 0 => { + Some(ParametricRef::center(handle)) + } + acadrust::EntityType::Point(_) + | acadrust::EntityType::Insert(_) + | acadrust::EntityType::Text(_) + | acadrust::EntityType::MText(_) + if grip_id == 0 => + { + Some(ParametricRef::point(handle, 0)) + } + _ => None, + } +} + /// The friendly, user-facing constraint types — the "what button did they /// click" vocabulary, one layer above the `cadkernel_constraints` primitives each maps /// onto (that mapping is `constraint_map`, a later stage; see the design @@ -576,6 +615,18 @@ impl super::Scene { !self.hidden_parametric_constraints.contains(&(scope, id)) } + pub fn should_display_parametric_constraint( + &self, + scope: ParametricScope, + id: ConstraintId, + related_entity_selected: bool, + display_mode: i16, + ) -> bool { + self.shown_parametric_constraints.contains(&(scope, id)) + || (display_mode & 1 != 0 && self.is_parametric_constraint_visible(scope, id)) + || (display_mode & 2 != 0 && related_entity_selected) + } + pub fn set_parametric_constraint_visibility( &mut self, scope: ParametricScope, @@ -601,8 +652,10 @@ impl super::Scene { for id in &ids { if visible { self.hidden_parametric_constraints.remove(&(scope, *id)); + self.shown_parametric_constraints.insert((scope, *id)); } else { self.hidden_parametric_constraints.insert((scope, *id)); + self.shown_parametric_constraints.remove(&(scope, *id)); } } ids.len() @@ -613,40 +666,155 @@ impl super::Scene { &self, scope: ParametricScope, handles: &[Handle], + settings: &crate::app::settings::AutoConstrainSettings, ) -> Vec<(ConstraintKind, Vec)> { use cadkernel::geom2d::{ - infer_constraints, Arc, Circle, ConstraintEndpoint, InferredConstraint, Line, - ParametricPrimitive, Tolerance, + infer_constraints_with_settings, Arc, Circle, ConstraintEndpoint, InferenceKind, + InferenceSettings, InferredConstraint, Line, ParametricPrimitive, }; + struct Source { + handle: Handle, + primitive: ParametricPrimitive, + whole: ParametricRef, + endpoints: [ParametricRef; 2], + } let mut sources = Vec::new(); for handle in handles { - let primitive = match self.document.get_entity(*handle) { - Some(acadrust::EntityType::Line(line)) => ParametricPrimitive::Line(Line { - start: [line.start.x, line.start.y], - end: [line.end.x, line.end.y], + match self.document.get_entity(*handle) { + Some(acadrust::EntityType::Line(line)) => sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Line(Line { + start: [line.start.x, line.start.y], + end: [line.end.x, line.end.y], + }), + whole: ParametricRef::whole(*handle), + endpoints: [ + ParametricRef::point(*handle, 0), + ParametricRef::point(*handle, 1), + ], }), - Some(acadrust::EntityType::Circle(circle)) => ParametricPrimitive::Circle(Circle { - centre: [circle.center.x, circle.center.y], - radius: circle.radius, + Some(acadrust::EntityType::Circle(circle)) => sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Circle(Circle { + centre: [circle.center.x, circle.center.y], + radius: circle.radius, + }), + whole: ParametricRef::whole(*handle), + endpoints: [ + ParametricRef::center(*handle), + ParametricRef::center(*handle), + ], }), - Some(acadrust::EntityType::Arc(arc)) => ParametricPrimitive::Arc(Arc { - centre: [arc.center.x, arc.center.y], - radius: arc.radius, - start_angle: arc.start_angle, - end_angle: arc.end_angle, + Some(acadrust::EntityType::Arc(arc)) => sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Arc(Arc { + centre: [arc.center.x, arc.center.y], + radius: arc.radius, + start_angle: arc.start_angle, + end_angle: arc.end_angle, + }), + whole: ParametricRef::whole(*handle), + endpoints: [ + ParametricRef::point(*handle, 0), + ParametricRef::point(*handle, 1), + ], }), + Some(acadrust::EntityType::LwPolyline(polyline)) => { + let Some(world) = crate::entities::curve::lwpolyline_world_xy(polyline) else { + continue; + }; + let count = world.vertices.len(); + for index in 0..count { + let next = index + 1; + if next >= count && !world.is_closed { + break; + } + if world.vertices[index].bulge.abs() > 1e-9 { + continue; + } + let a = world.vertices[index].location; + let b = world.vertices[next % count].location; + sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Line(Line { + start: [a.x, a.y], + end: [b.x, b.y], + }), + whole: ParametricRef::segment(*handle, index), + endpoints: [ + ParametricRef::point(*handle, index as i32), + ParametricRef::point(*handle, (next % count) as i32), + ], + }); + } + } + Some(acadrust::EntityType::Polyline2D(polyline)) => { + let count = polyline.vertices.len(); + for index in 0..count { + let next = index + 1; + if next >= count && !polyline.is_closed() { + break; + } + if polyline.vertices[index].bulge.abs() > 1e-9 { + continue; + } + let a = polyline.vertices[index].location; + let b = polyline.vertices[next % count].location; + sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Line(Line { + start: [a.x, a.y], + end: [b.x, b.y], + }), + whole: ParametricRef::segment(*handle, index), + endpoints: [ + ParametricRef::point(*handle, index as i32), + ParametricRef::point(*handle, (next % count) as i32), + ], + }); + } + } _ => continue, - }; - sources.push((*handle, primitive)); + } } - let primitives: Vec<_> = sources.iter().map(|(_, primitive)| *primitive).collect(); + let primitives: Vec<_> = sources.iter().map(|source| source.primitive).collect(); let marker = |endpoint| match endpoint { - ConstraintEndpoint::Start => 0, - ConstraintEndpoint::End => 1, + ConstraintEndpoint::Start => 0usize, + ConstraintEndpoint::End => 1usize, + }; + let kind = |kind| match kind { + crate::app::settings::AutoConstraintKind::Coincident => InferenceKind::Coincident, + crate::app::settings::AutoConstraintKind::Collinear => InferenceKind::Collinear, + crate::app::settings::AutoConstraintKind::Parallel => InferenceKind::Parallel, + crate::app::settings::AutoConstraintKind::Perpendicular => { + InferenceKind::Perpendicular + } + crate::app::settings::AutoConstraintKind::Tangent => InferenceKind::Tangent, + crate::app::settings::AutoConstraintKind::Concentric => InferenceKind::Concentric, + crate::app::settings::AutoConstraintKind::Horizontal => InferenceKind::Horizontal, + crate::app::settings::AutoConstraintKind::Vertical => InferenceKind::Vertical, + crate::app::settings::AutoConstraintKind::Equal => InferenceKind::Equal, + }; + let inference_settings = InferenceSettings { + priority: settings + .priority + .iter() + .copied() + .filter(|candidate| settings.enabled.contains(candidate)) + .map(kind) + .collect(), + distance_tolerance: settings.distance_tolerance, + angle_tolerance_radians: settings.angle_tolerance_deg.to_radians(), + tangent_must_share_point: settings.tangent_must_share_point, + perpendicular_must_intersect: settings.perpendicular_must_intersect, }; let mut mapped: Vec<_> = - infer_constraints(&primitives, Tolerance::new(1e-6), 0.5_f64.to_radians()) + infer_constraints_with_settings(&primitives, &inference_settings) .into_iter() + .filter(|relation| { + !matches!(relation, InferredConstraint::Coincident { first, second, .. } + if sources[*first].handle == sources[*second].handle) + }) .map(|relation| match relation { InferredConstraint::Coincident { first, @@ -656,53 +824,57 @@ impl super::Scene { } => ( ConstraintKind::Coincident, vec![ - ParametricRef::point(sources[first].0, marker(first_endpoint)), - ParametricRef::point(sources[second].0, marker(second_endpoint)), + sources[first].endpoints[marker(first_endpoint)], + sources[second].endpoints[marker(second_endpoint)], ], ), InferredConstraint::Collinear { first, second } => ( ConstraintKind::Colinear, vec![ - ParametricRef::whole(sources[first].0), - ParametricRef::whole(sources[second].0), + sources[first].whole, + sources[second].whole, ], ), InferredConstraint::Concentric { first, second } => ( ConstraintKind::Concentric, vec![ - ParametricRef::center(sources[first].0), - ParametricRef::center(sources[second].0), + ParametricRef::center(sources[first].handle), + ParametricRef::center(sources[second].handle), ], ), InferredConstraint::Parallel { first, second } => ( ConstraintKind::Parallel, vec![ - ParametricRef::whole(sources[first].0), - ParametricRef::whole(sources[second].0), + sources[first].whole, + sources[second].whole, ], ), InferredConstraint::Perpendicular { first, second } => ( ConstraintKind::Perpendicular, vec![ - ParametricRef::whole(sources[first].0), - ParametricRef::whole(sources[second].0), + sources[first].whole, + sources[second].whole, ], ), InferredConstraint::Horizontal { entity } => ( ConstraintKind::Horizontal, - vec![ParametricRef::whole(sources[entity].0)], + vec![sources[entity].whole], ), InferredConstraint::Vertical { entity } => ( ConstraintKind::Vertical, - vec![ParametricRef::whole(sources[entity].0)], + vec![sources[entity].whole], ), InferredConstraint::Tangent { first, second } => ( ConstraintKind::Tangent, vec![ - ParametricRef::whole(sources[first].0), - ParametricRef::whole(sources[second].0), + sources[first].whole, + sources[second].whole, ], ), + InferredConstraint::Equal { first, second } => ( + ConstraintKind::Equal, + vec![sources[first].whole, sources[second].whole], + ), }) .collect(); if let Some(existing) = self.parametric_constraint_set(scope) { @@ -1174,7 +1346,11 @@ mod tests { ); let inferred = - scene.inferred_parametric_constraints(ParametricScope::ModelSpace, &[first, second]); + scene.inferred_parametric_constraints( + ParametricScope::ModelSpace, + &[first, second], + &crate::app::settings::AutoConstrainSettings::default(), + ); assert!(!inferred.iter().any(|(kind, refs)| { *kind == ConstraintKind::Horizontal && *refs == [ParametricRef::whole(first)] diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 20e9581bb..9057a3410 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -1549,6 +1549,44 @@ type SolveResult = ( )>, ); +fn retained_line_length(entity: &EntityType, segment: Option) -> Option { + match (entity, segment) { + (EntityType::Line(line), None) => Some((line.end - line.start).length()), + (EntityType::LwPolyline(polyline), Some(index)) => { + let world = crate::entities::curve::lwpolyline_world_xy(polyline)?; + let a = world.vertices.get(index)?.location; + let b = if let Some(vertex) = world.vertices.get(index + 1) { + vertex.location + } else if world.is_closed { + world.vertices.first()?.location + } else { + return None; + }; + Some((b - a).length()) + } + (EntityType::Polyline2D(polyline), Some(index)) => { + let a = polyline.vertices.get(index)?.location; + let b = if let Some(vertex) = polyline.vertices.get(index + 1) { + vertex.location + } else if polyline.is_closed() { + polyline.vertices.first()?.location + } else { + return None; + }; + Some((b - a).length()) + } + _ => None, + } +} + +fn retained_radius(entity: &EntityType) -> Option { + match entity { + EntityType::Circle(circle) => Some(circle.radius), + EntityType::Arc(arc) => Some(arc.radius), + _ => None, + } +} + /// Rebuilds `set`'s entire `cadkernel_constraints::System` from current document /// geometry, solves it, and returns the resulting entity states for every /// handle whose registered coordinates actually moved beyond floating-point @@ -1564,6 +1602,9 @@ fn solve_scope( document: &acadrust::CadDocument, drawing_params: &ParameterTable, set: &ParametricConstraintSet, + driven_refs: &[ParametricRef], + retain_size: bool, + retained_before: &HashMap>, ) -> Option { let params = if set.local_parameters.is_empty() { drawing_params @@ -1597,6 +1638,81 @@ fn solve_scope( return None; } + // Preserve current line lengths and radii for this solve when + // CONSTRAINTSOLVEMODE requests it. These temporary equations guide the + // solution without becoming persistent dimensional constraints. + if retain_size { + for (handle, geom) in &cache { + match geom { + EntityGeom::Line(line) => { + let current_length = { + let store = sys.store(); + let dx = store.get(line.p2.x) - store.get(line.p1.x); + let dy = store.get(line.p2.y) - store.get(line.p1.y); + (dx * dx + dy * dy).sqrt() + }; + let length = retained_before + .get(handle) + .and_then(|entity| retained_line_length(entity, None)) + .unwrap_or(current_length); + let target = sys.add_param(length, true); + sys.add_constraint(Rc::new(P2PDistance::new(line.p1, line.p2, target))); + } + EntityGeom::Polyline { + points, + straight, + closed, + } => { + for index in 0..straight.len() { + if !straight[index] { + continue; + } + let Some(a) = points.get(index).copied() else { + continue; + }; + let b = if let Some(point) = points.get(index + 1).copied() { + Some(point) + } else if *closed { + points.first().copied() + } else { + None + }; + let Some(b) = b else { continue }; + let current_length = { + let store = sys.store(); + let dx = store.get(b.x) - store.get(a.x); + let dy = store.get(b.y) - store.get(a.y); + (dx * dx + dy * dy).sqrt() + }; + let length = retained_before + .get(handle) + .and_then(|entity| retained_line_length(entity, Some(index))) + .unwrap_or(current_length); + let target = sys.add_param(length, true); + sys.add_constraint(Rc::new(P2PDistance::new(a, b, target))); + } + } + EntityGeom::Circle(circle) => { + let radius = retained_before + .get(handle) + .and_then(|entity| retained_radius(entity)) + .unwrap_or_else(|| sys.store().get(circle.rad)); + let target = sys.add_param(radius, true); + sys.add_constraint(Rc::new(Equal::new(circle.rad, target, 1.0))); + } + EntityGeom::Arc(arc) => { + let radius = retained_before + .get(handle) + .and_then(|entity| retained_radius(entity)) + .unwrap_or_else(|| sys.store().get(arc.circle.rad)); + let target = sys.add_param(radius, true); + sys.add_constraint(Rc::new(Equal::new(arc.circle.rad, target, 1.0))); + } + _ => {} + } + } + } + // Arc rules (this module's doc comment): keep every registered arc's // `start`/`end` points consistent with its center/radius/angle, // unconditionally — the kernel adds these the moment an arc @@ -1633,6 +1749,33 @@ fn solve_scope( ))); } + // A point explicitly edited by a grip, STRETCH, or Properties is the + // driver's target for this solve. Pin its current coordinates for this + // solve only so constrained neighbours respond while the chosen point + // stays at the exact cursor or typed coordinate. + for reference in driven_refs { + if !set + .constraints + .iter() + .any(|constraint| constraint.refs.iter().any(|r| r.entity == reference.entity)) + { + continue; + } + let Some(point) = + resolve_constraint_point(document, &mut sys, &mut cache, *reference) + else { + continue; + }; + let (x, y) = { + let store = sys.store(); + (store.get(point.x), store.get(point.y)) + }; + let target_x = sys.add_param(x, true); + let target_y = sys.add_param(y, true); + sys.add_constraint(Rc::new(Equal::new(point.x, target_x, 1.0))); + sys.add_constraint(Rc::new(Equal::new(point.y, target_y, 1.0))); + } + // Ellipse rules (same motivation as arc rules, different mechanism): // `cadkernel_constraints::geo::Ellipse` stores `focus1` as an absolute point, not an // offset from `center`. If some constraint (e.g. Concentric) pulls only @@ -2151,9 +2294,43 @@ impl Scene { /// the resulting `(Handle, ChangeKind::Modified)` entries the same way /// `refresh_associative_dimensions`/`_hatches` do, for `bump_entities` /// to fold into its own `changes` vec. + #[allow(dead_code)] pub(crate) fn refresh_parametric_constraints( &mut self, changes: &[(Handle, ChangeKind)], + ) -> Vec<(Handle, ChangeKind)> { + self.refresh_parametric_constraints_with_policy(changes, &[], false) + } + + #[allow(dead_code)] + pub(crate) fn refresh_parametric_constraints_with_driven( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[ParametricRef], + ) -> Vec<(Handle, ChangeKind)> { + self.refresh_parametric_constraints_with_policy(changes, driven_refs, false) + } + + pub(crate) fn refresh_parametric_constraints_with_policy( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[ParametricRef], + retain_size: bool, + ) -> Vec<(Handle, ChangeKind)> { + self.refresh_parametric_constraints_with_originals( + changes, + driven_refs, + retain_size, + &[], + ) + } + + pub(crate) fn refresh_parametric_constraints_with_originals( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], ) -> Vec<(Handle, ChangeKind)> { // An erased entity takes its constraints with it instead of leaving // dangling references. Done first, @@ -2185,6 +2362,25 @@ impl Scene { } } + let retained_before: HashMap> = if retain_size { + let mut retained: HashMap> = self + .undo_recording + .as_ref() + .into_iter() + .flat_map(|recording| recording.before.iter()) + .filter_map(|(handle, entity)| { + entity + .as_ref() + .map(|entity| (*handle, std::sync::Arc::clone(entity))) + }) + .collect(); + for (handle, entity) in retained_originals { + retained.insert(*handle, std::sync::Arc::new(entity.clone())); + } + retained + } else { + HashMap::new() + }; let mut result = Vec::new(); for i in 0..self.parametric_constraints.len() { let touched = changes.iter().any(|(handle, _)| { @@ -2200,6 +2396,9 @@ impl Scene { &self.document, &self.named_parameters, &self.parametric_constraints[i], + driven_refs, + retain_size, + &retained_before, ) else { continue; }; @@ -2224,10 +2423,17 @@ impl Scene { pub(crate) fn solve_parametric_constraints_preview( &self, touched: &[Handle], + driven_refs: &[ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], ) -> Vec<(Handle, EntityType)> { if self.parametric_constraints.is_empty() || touched.is_empty() { return Vec::new(); } + let retained_before: HashMap> = retained_originals + .iter() + .map(|(handle, entity)| (*handle, std::sync::Arc::new(entity.clone()))) + .collect(); let mut result = Vec::new(); for set in &self.parametric_constraints { let is_touched = touched @@ -2236,8 +2442,14 @@ impl Scene { if !is_touched { continue; } - let Some((solved, _dof, _conflicts)) = - solve_scope(&self.document, &self.named_parameters, set) + let Some((solved, _dof, _conflicts)) = solve_scope( + &self.document, + &self.named_parameters, + set, + driven_refs, + retain_size, + &retained_before, + ) else { continue; }; @@ -2515,7 +2727,7 @@ mod tests { } let b_before = scene.document.get_entity(b).cloned(); - let solved = scene.solve_parametric_constraints_preview(&[a]); + let solved = scene.solve_parametric_constraints_preview(&[a], &[], false, &[]); assert_eq!( scene.document.get_entity(b), diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 46fdc260f..a52a57543 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -1001,6 +1001,7 @@ impl Scene { self.hover_highlight = None; hover_changed = true; } + hover_changed |= self.constraint_hover_highlights.remove(&h); self.hatches.remove(&h); self.images.remove(&h); self.meshes.remove(&h); diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index efde7319e..601a59482 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -3435,7 +3435,10 @@ impl Scene { &self, inst: &ViewportInstance, ) -> Arc> { - if self.selected.is_empty() && self.hover_highlight.is_none() { + if self.selected.is_empty() + && self.hover_highlight.is_none() + && self.constraint_hover_highlights.is_empty() + { return Arc::new(Vec::new()); } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 4cd3418d2..3b6b9468c 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -94,6 +94,27 @@ fn constraint_glyph_offsets(glyphs: &[(Point, [f32; 2], String, bool)]) -> Vec Option { + let offsets = constraint_glyph_offsets(glyphs); + glyphs + .iter() + .zip(offsets) + .enumerate() + .rev() + .find_map(|(index, ((anchor, outward, label, _), tangent_offset))| { + let (top_left, size) = + constraint_glyph_box(*anchor, *outward, label, tangent_offset); + (cursor.x >= top_left.x + && cursor.x <= top_left.x + size.width + && cursor.y >= top_left.y + && cursor.y <= top_left.y + size.height) + .then_some(index) + }) +} + /// Convert CURSORSIZE to a screen-space arm length while keeping the original /// 60 px cursor at the default value and the full-viewport result at 100. pub(crate) fn crosshair_arm_px(bounds: iced::Rectangle, value: i32) -> f32 { @@ -989,6 +1010,11 @@ impl canvas::Program for SelectionCanvas { } } } + if let Some(pos) = cursor.position_in(bounds) { + if constraint_glyph_hit(&self.constraint_glyphs, pos).is_some() { + return mouse::Interaction::Pointer; + } + } // The resize cursor over a divider is supplied by the input pane_grid // layered above this overlay; `draw` only suppresses the CAD crosshair // there (see `divider_under`). @@ -1703,7 +1729,6 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&b1, stroke.clone()); frame.stroke(&b2, stroke); } - // Constraint glyphs are visual-only and have no hit testing. if !self.constraint_glyphs.is_empty() { let offsets = constraint_glyph_offsets(&self.constraint_glyphs); let normal_bg = theme.palette().primary.base.color; diff --git a/src/ui/window/auto_constrain_settings.rs b/src/ui/window/auto_constrain_settings.rs new file mode 100644 index 000000000..2a5afa621 --- /dev/null +++ b/src/ui/window/auto_constrain_settings.rs @@ -0,0 +1,143 @@ +use crate::app::settings::AutoConstrainSettings; +use crate::app::Message; +use iced::widget::{button, checkbox, column, container, row, scrollable, text, text_input, Space}; +use iced::{Element, Fill}; + +pub fn view_window<'a>( + settings: &'a AutoConstrainSettings, + selected_row: usize, + distance_input: &'a str, + angle_input: &'a str, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let heading = row![ + text(crate::t!("Priority")).width(70), + text(crate::t!("Constraint Type")).width(Fill), + text(crate::t!("Apply")).width(60), + ] + .spacing(8); + + let mut list = column![heading].spacing(3); + for (index, kind) in settings.priority.iter().copied().enumerate() { + let enabled = settings.enabled.contains(&kind); + let label = row![ + text(format!("{}", index + 1)).width(70), + text(crate::t!(kind.label())).width(Fill), + checkbox(enabled) + .on_toggle(move |_| Message::AutoConstrainToggleKind(kind)) + .width(60), + ] + .spacing(8) + .align_y(iced::Center); + list = list.push( + button(label) + .on_press(Message::AutoConstrainSelectRow(index)) + .style(if selected_row == index { + button::primary + } else { + button::text + }) + .width(Fill) + .padding([5, 8]), + ); + } + + let reorder = column![ + button(text(crate::t!("Move Up"))) + .on_press_maybe((selected_row > 0).then_some(Message::AutoConstrainMoveUp)) + .width(Fill), + button(text(crate::t!("Move Down"))) + .on_press_maybe( + (selected_row + 1 < settings.priority.len()) + .then_some(Message::AutoConstrainMoveDown) + ) + .width(Fill), + Space::new().height(8), + button(text(crate::t!("Select All"))) + .on_press(Message::AutoConstrainSelectAll) + .width(Fill), + button(text(crate::t!("Clear All"))) + .on_press(Message::AutoConstrainClearAll) + .width(Fill), + button(text(crate::t!("Reset"))) + .on_press(Message::AutoConstrainReset) + .width(Fill), + ] + .spacing(6) + .width(125); + + let types = row![ + container(scrollable(list).height(260)) + .padding(6) + .width(Fill), + reorder + ] + .spacing(12); + + let conditions = column![ + checkbox(settings.tangent_must_share_point) + .label(crate::t!("Tangent objects must share an intersection point")) + .on_toggle(|_| Message::AutoConstrainToggleTangentPoint), + checkbox(settings.perpendicular_must_intersect) + .label(crate::t!("Perpendicular objects must share an intersection point")) + .on_toggle(|_| Message::AutoConstrainTogglePerpendicularIntersection), + ] + .spacing(8); + + let tolerances = column![ + text(crate::t!("Tolerances")).size(14), + row![ + text(crate::t!("Distance")).width(130), + text_input("0.05", distance_input) + .on_input(Message::AutoConstrainDistanceChanged) + .width(150), + ] + .align_y(iced::Center), + row![ + text(crate::t!("Angle")).width(130), + text_input("1", angle_input) + .on_input(Message::AutoConstrainAngleChanged) + .width(150), + text("°"), + ] + .align_y(iced::Center), + ] + .spacing(7); + + let actions = row![ + Space::new().width(Fill), + button(text(crate::t!("OK"))) + .on_press(Message::AutoConstrainOk) + .style(button::primary) + .padding([6, 18]), + button(text(crate::t!("Apply"))) + .on_press(Message::AutoConstrainApply) + .style(button::secondary) + .padding([6, 18]), + button(text(crate::t!("Cancel"))) + .on_press(Message::AutoConstrainCancel) + .style(button::secondary) + .padding([6, 18]), + ] + .spacing(8) + .align_y(iced::Center); + + container( + column![ + text(crate::t!("Auto Constrain")).size(16), + types, + conditions, + tolerances, + Space::new().height(Fill), + actions, + ] + .spacing(14) + .width(sizing.width) + .height(sizing.height), + ) + .padding([14, 16]) + .width(sizing.width) + .height(sizing.height) + .style(container::rounded_box) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 32b0f5d3a..09b3b2314 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -5,6 +5,7 @@ pub mod layer_state_manager; pub mod drawing_units; pub mod geometric_tolerance; pub mod drafting_settings; +pub mod auto_constrain_settings; pub mod layer_translator; pub mod plot; pub mod print_all; From b9ef0e3286c1f571bba9a2cd9c92d7c66583df37 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:09:59 +0300 Subject: [PATCH 17/33] Match default automatic constraint set --- src/app/settings.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/settings.rs b/src/app/settings.rs index 12cf09c9d..6e075e9bb 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -67,7 +67,14 @@ impl Default for AutoConstrainSettings { fn default() -> Self { Self { priority: AutoConstraintKind::ALL.to_vec(), - enabled: AutoConstraintKind::ALL.to_vec(), + // Equal remains available in the priority list, but the reference + // application does not enable it in a fresh profile. Enabling it + // by default adds a redundant relation between equal-length line + // segments (including adjacent polyline segments). + enabled: AutoConstraintKind::ALL + .into_iter() + .filter(|kind| *kind != AutoConstraintKind::Equal) + .collect(), tangent_must_share_point: true, perpendicular_must_intersect: true, distance_tolerance: 0.05, From d9bad54e1ea8559af094de16b9fc4fe007a98e5a Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Tue, 15 Sep 2026 11:29:57 +0200 Subject: [PATCH 18/33] macOS DMG: add an Applications shortcut for drag-to-install The DMG dropped just the bare .app with no indication of where it's supposed to go, unlike the "drag the icon onto Applications" layout every other Mac app uses. Stage the .app next to a symlink to /Applications before building the image, then lay out the Finder window's icon view (via a temporary read-write DMG, since a compressed UDZO image can't be mounted for editing afterward) so both items are visible side by side. The Finder/AppleScript layout step is best-effort and fails gracefully: this script mirrors the CI build-macos job, which likely has no interactive Finder session for AppleScript to reach at all, so the essential fix (the symlink itself, which doesn't depend on Finder scripting) still lands even when the cosmetic pass can't run. Fixes #769. Co-Authored-By: Claude Sonnet 5 --- packaging/build_macos_signed.sh | 64 +++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/packaging/build_macos_signed.sh b/packaging/build_macos_signed.sh index bbed9a20c..87173f229 100755 --- a/packaging/build_macos_signed.sh +++ b/packaging/build_macos_signed.sh @@ -112,13 +112,73 @@ codesign --verify --strict --verbose=2 "$APP" echo "==> dmg" DMG="$DIST/OpenCADStudio-v$VERSION-macos-arm64.dmg" rm -f "$DMG" + +# Stage the .app next to a symlink to /Applications so the DMG opens onto +# the familiar "drag the icon onto Applications" installer layout, rather +# than dropping just the bare .app into the user's Downloads-mounted volume +# with no indication of where it's supposed to go (#769). +STAGING="$DIST/dmg-staging" +rm -rf "$STAGING" +mkdir -p "$STAGING" +cp -R "$APP" "$STAGING/" +ln -s /Applications "$STAGING/Applications" + +# Build read-write first so Finder can save the window's icon layout, then +# convert to the compressed read-only image the CI job's own DMG step +# already produces — same two-stage approach that layout customization +# always needs, since a UDZO image can't be mounted for editing afterward. +RW_DMG="$DIST/OpenCADStudio-rw.dmg" +rm -f "$RW_DMG" for i in 1 2 3 4 5; do - if hdiutil create -volname "Open CAD Studio" -srcfolder "$APP" -ov -format UDZO "$DMG"; then + if hdiutil create -volname "Open CAD Studio" -srcfolder "$STAGING" -ov -format UDRW -fs HFS+ "$RW_DMG"; then break fi - echo "hdiutil failed (attempt $i), retrying..." >&2 + echo "hdiutil create (rw) failed (attempt $i), retrying..." >&2 sleep 3 done +[ -f "$RW_DMG" ] || { echo "hdiutil create (rw) failed permanently" >&2; exit 1; } + +MOUNT_DIR="$(mktemp -d /tmp/ocs-dmg-mount.XXXXXX)" +hdiutil attach "$RW_DMG" -mountpoint "$MOUNT_DIR" -quiet +# Finder's own volume list can lag a moment behind `hdiutil attach` +# returning, and "container window of " isn't a valid reference the +# way "container window of disk " is — so poll by name (up to 15s) +# before touching it, rather than a fixed sleep or a path-based reference. +osascript <&2 +tell application "Finder" + repeat 30 times + if (exists disk "Open CAD Studio") then exit repeat + delay 0.5 + end repeat + tell disk "Open CAD Studio" + open + set current view of container window to icon view + set toolbar visible of container window to false + set statusbar visible of container window to false + set the bounds of container window to {200, 120, 760, 480} + set viewOptions to the icon view options of container window + set arrangement of viewOptions to not arranged + set icon size of viewOptions to 96 + set position of item "OpenCADStudio.app" of container window to {140, 180} + set position of item "Applications" of container window to {420, 180} + close + open + update without registering applications + delay 1 + end tell +end tell +APPLESCRIPT +hdiutil detach "$MOUNT_DIR" -quiet + +for i in 1 2 3 4 5; do + if hdiutil convert "$RW_DMG" -format UDZO -ov -o "$DMG"; then + break + fi + echo "hdiutil convert failed (attempt $i), retrying..." >&2 + sleep 3 +done +rm -f "$RW_DMG" +rm -rf "$STAGING" [ -f "$DMG" ] || { echo "hdiutil failed permanently" >&2; exit 1; } if [ -n "${NOTARY_PROFILE:-}" ] && [ "$DEVELOPER_ID" != "-" ]; then From f61174eaadb4e210eda88adaf5428b78a587a7a1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 15 Sep 2026 12:54:00 +0300 Subject: [PATCH 19/33] Polish automatic constraint integration --- src/app/settings.rs | 6 ++---- src/scene/mod.rs | 2 +- src/scene/parametric_constraints.rs | 23 +++++++++++++++++------ 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/app/settings.rs b/src/app/settings.rs index 6e075e9bb..9c2757542 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -67,10 +67,8 @@ impl Default for AutoConstrainSettings { fn default() -> Self { Self { priority: AutoConstraintKind::ALL.to_vec(), - // Equal remains available in the priority list, but the reference - // application does not enable it in a fresh profile. Enabling it - // by default adds a redundant relation between equal-length line - // segments (including adjacent polyline segments). + // Keep Equal available without creating redundant relations + // between equal-length segments by default. enabled: AutoConstraintKind::ALL .into_iter() .filter(|kind| *kind != AutoConstraintKind::Equal) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 9d3e03697..59eb787f3 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -12591,7 +12591,7 @@ mod layout_cache_tests { let mut arc = acadrust::entities::Arc::default(); arc.radius = 25.0; arc.start_angle = 0.0; - arc.end_angle = 3.14159; + arc.end_angle = std::f64::consts::PI; let a_h = s.add_entity(EntityType::Arc(arc)); let line = acadrust::entities::Line::from_points( diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 116172030..c5843fa8b 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -1356,9 +1356,18 @@ mod tests { std::f64::consts::PI, )); - assert_eq!(driven_ref_for_grip(&arc, handle, 0), Some(ParametricRef::center(handle))); - assert_eq!(driven_ref_for_grip(&arc, handle, 1), Some(ParametricRef::point(handle, 0))); - assert_eq!(driven_ref_for_grip(&arc, handle, 2), Some(ParametricRef::point(handle, 1))); + assert_eq!( + driven_ref_for_grip(&arc, handle, 0), + Some(ParametricRef::center(handle)) + ); + assert_eq!( + driven_ref_for_grip(&arc, handle, 1), + Some(ParametricRef::point(handle, 0)) + ); + assert_eq!( + driven_ref_for_grip(&arc, handle, 2), + Some(ParametricRef::point(handle, 1)) + ); assert_eq!(driven_ref_for_grip(&arc, handle, 3), None); } @@ -1528,9 +1537,11 @@ mod tests { fn hidden_constraint_stays_hidden_when_related_entity_is_selected() { let mut scene = super::super::Scene::new(); let scope = ParametricScope::ModelSpace; - let id = scene - .parametric_constraint_set_mut(scope) - .add(ConstraintKind::Horizontal, vec![ParametricRef::whole(h(1))], None); + let id = scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(h(1))], + None, + ); scene.hidden_parametric_constraints.insert((scope, id)); assert!(!scene.should_display_parametric_constraint(scope, id, true, 2)); From 31e769a948ae1b9971b6585b8ae5a0d474bc968b Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 15 Sep 2026 12:56:47 +0300 Subject: [PATCH 20/33] Simplify macOS DMG staging --- packaging/build_macos_signed.sh | 58 +++------------------------------ 1 file changed, 4 insertions(+), 54 deletions(-) diff --git a/packaging/build_macos_signed.sh b/packaging/build_macos_signed.sh index 87173f229..97ef76563 100755 --- a/packaging/build_macos_signed.sh +++ b/packaging/build_macos_signed.sh @@ -113,71 +113,21 @@ echo "==> dmg" DMG="$DIST/OpenCADStudio-v$VERSION-macos-arm64.dmg" rm -f "$DMG" -# Stage the .app next to a symlink to /Applications so the DMG opens onto -# the familiar "drag the icon onto Applications" installer layout, rather -# than dropping just the bare .app into the user's Downloads-mounted volume -# with no indication of where it's supposed to go (#769). +# Stage the app next to the install-location shortcut (#769). STAGING="$DIST/dmg-staging" rm -rf "$STAGING" mkdir -p "$STAGING" cp -R "$APP" "$STAGING/" ln -s /Applications "$STAGING/Applications" -# Build read-write first so Finder can save the window's icon layout, then -# convert to the compressed read-only image the CI job's own DMG step -# already produces — same two-stage approach that layout customization -# always needs, since a UDZO image can't be mounted for editing afterward. -RW_DMG="$DIST/OpenCADStudio-rw.dmg" -rm -f "$RW_DMG" for i in 1 2 3 4 5; do - if hdiutil create -volname "Open CAD Studio" -srcfolder "$STAGING" -ov -format UDRW -fs HFS+ "$RW_DMG"; then + if hdiutil create -volname "Open CAD Studio" -srcfolder "$STAGING" -ov -format UDZO "$DMG"; then break fi - echo "hdiutil create (rw) failed (attempt $i), retrying..." >&2 + rm -f "$DMG" + echo "hdiutil failed (attempt $i), retrying..." >&2 sleep 3 done -[ -f "$RW_DMG" ] || { echo "hdiutil create (rw) failed permanently" >&2; exit 1; } - -MOUNT_DIR="$(mktemp -d /tmp/ocs-dmg-mount.XXXXXX)" -hdiutil attach "$RW_DMG" -mountpoint "$MOUNT_DIR" -quiet -# Finder's own volume list can lag a moment behind `hdiutil attach` -# returning, and "container window of " isn't a valid reference the -# way "container window of disk " is — so poll by name (up to 15s) -# before touching it, rather than a fixed sleep or a path-based reference. -osascript <&2 -tell application "Finder" - repeat 30 times - if (exists disk "Open CAD Studio") then exit repeat - delay 0.5 - end repeat - tell disk "Open CAD Studio" - open - set current view of container window to icon view - set toolbar visible of container window to false - set statusbar visible of container window to false - set the bounds of container window to {200, 120, 760, 480} - set viewOptions to the icon view options of container window - set arrangement of viewOptions to not arranged - set icon size of viewOptions to 96 - set position of item "OpenCADStudio.app" of container window to {140, 180} - set position of item "Applications" of container window to {420, 180} - close - open - update without registering applications - delay 1 - end tell -end tell -APPLESCRIPT -hdiutil detach "$MOUNT_DIR" -quiet - -for i in 1 2 3 4 5; do - if hdiutil convert "$RW_DMG" -format UDZO -ov -o "$DMG"; then - break - fi - echo "hdiutil convert failed (attempt $i), retrying..." >&2 - sleep 3 -done -rm -f "$RW_DMG" rm -rf "$STAGING" [ -f "$DMG" ] || { echo "hdiutil failed permanently" >&2; exit 1; } From 7c90b8ea7f7b232d0150fd99b0bfc6e9e9254911 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:10:01 +0300 Subject: [PATCH 21/33] Center constraint glyph labels --- src/ui/overlay.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 3b6b9468c..131e445a1 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -1757,11 +1757,13 @@ impl canvas::Program for SelectionCanvas { frame.fill_text(canvas::Text { content: label.clone(), position: Point::new( - top_left.x + CONSTRAINT_GLYPH_PAD_X, - top_left.y + CONSTRAINT_GLYPH_PAD_Y, + top_left.x + size.width * 0.5, + top_left.y + size.height * 0.5, ), color: fg, size: iced::Pixels(CONSTRAINT_GLYPH_SIZE), + align_x: iced::alignment::Horizontal::Center.into(), + align_y: iced::alignment::Vertical::Center, shaping: iced::advanced::text::Shaping::Advanced, ..Default::default() }); From a457541c211af096d8c17d78d6927cb3146f3b43 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:10:01 +0300 Subject: [PATCH 22/33] Center constraint glyph labels --- src/ui/overlay.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 85dccb70b..4e9b2fab7 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -1789,11 +1789,13 @@ impl canvas::Program for SelectionCanvas { frame.fill_text(canvas::Text { content: label.clone(), position: Point::new( - top_left.x + CONSTRAINT_GLYPH_PAD_X, - top_left.y + CONSTRAINT_GLYPH_PAD_Y, + top_left.x + size.width * 0.5, + top_left.y + size.height * 0.5, ), color: fg, size: iced::Pixels(CONSTRAINT_GLYPH_SIZE), + align_x: iced::alignment::Horizontal::Center.into(), + align_y: iced::alignment::Vertical::Center, shaping: iced::advanced::text::Shaping::Advanced, ..Default::default() }); From c3101919d7db6a6b5b471a6187bb5cde9d19bdc7 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:54:47 +0300 Subject: [PATCH 23/33] Show constraint action points on hover --- src/app/view/mod.rs | 20 +++- src/scene/parametric_constraints.rs | 158 +++++++++++++++++++++++++++- src/ui/overlay.rs | 48 +++++++-- 3 files changed, 210 insertions(+), 16 deletions(-) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 6730f4509..943119b3d 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -813,7 +813,14 @@ bg={bg_ms:.1}ms n={view_count}" .active_cmd .as_ref() .is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering()); - let constraint_glyphs: Vec<(iced::Point, [f32; 2], String, bool, bool)> = if is_paper { + let constraint_glyphs: Vec<( + iced::Point, + [f32; 2], + String, + bool, + bool, + Vec, + )> = if is_paper { Vec::new() } else { let scope = tab.current_parametric_scope(); @@ -825,9 +832,16 @@ bg={bg_ms:.1}ms n={view_count}" self.constraint_bar_display, ) .into_iter() - .map(|(id, point, direction, label, is_conflicting)| { + .map(|(id, point, direction, label, is_conflicting, hover_points)| { let selected = tab.scene.selected_constraint == Some(id); - (point, direction, label, is_conflicting, selected) + ( + point, + direction, + label, + is_conflicting, + selected, + hover_points, + ) }) .collect() }; diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index c5843fa8b..8ad6cffd6 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -606,6 +606,123 @@ pub(crate) fn glyph_placement( } } +/// World-space locations that explain what a hovered constraint acts on. +/// Point constraints expose their referenced point directly; curve relations +/// expose the contact or intersection that makes the relation visible. +fn constraint_segment_endpoints( + document: &acadrust::CadDocument, + reference: ParametricRef, +) -> Option<[Vector3; 2]> { + let segment = reference.segment_index()?; + let entity = document.get_entity(reference.entity)?; + let points = super::dimension_assoc::source_points(entity); + let closed = match entity { + acadrust::EntityType::LwPolyline(polyline) => polyline.is_closed, + acadrust::EntityType::Polyline2D(polyline) => polyline.is_closed(), + _ => return None, + }; + let first = *points.get(segment)?; + let second = if segment + 1 < points.len() { + points[segment + 1] + } else if closed { + *points.first()? + } else { + return None; + }; + Some([first, second]) +} + +fn constraint_reference_curve_xy( + document: &acadrust::CadDocument, + reference: ParametricRef, +) -> Option { + if let Some([first, second]) = constraint_segment_endpoints(document, reference) { + return Some(cadkernel::geom2d::Curve::Line(cadkernel::geom2d::Line { + start: [first.x, first.y], + end: [second.x, second.y], + })); + } + crate::entities::curve::entity_curve_xy(document.get_entity(reference.entity)?) +} + +pub(crate) fn constraint_hover_points( + document: &acadrust::CadDocument, + constraint: &ParametricConstraint, +) -> Vec { + let mut points = Vec::new(); + let push_unique = |points: &mut Vec, point: Vector3| { + if point.x.is_finite() + && point.y.is_finite() + && point.z.is_finite() + && !points + .iter() + .any(|existing| (*existing - point).length_squared() <= 1.0e-12) + { + points.push(point); + } + }; + + for reference in &constraint.refs { + let Some(marker) = reference.marker else { + continue; + }; + let Some(entity) = document.get_entity(reference.entity) else { + continue; + }; + if let Some(point) = resolve_point(entity, marker) { + push_unique(&mut points, point); + } + } + + if matches!( + constraint.kind, + ConstraintKind::Horizontal | ConstraintKind::Vertical + ) { + for reference in &constraint.refs { + let Some(entity) = document.get_entity(reference.entity) else { + continue; + }; + if let Some(endpoints) = constraint_segment_endpoints(document, *reference) { + for point in endpoints { + push_unique(&mut points, point); + } + } else if matches!(entity, acadrust::EntityType::Line(_)) { + for point in super::dimension_assoc::source_points(entity) { + push_unique(&mut points, point); + } + } + } + } + + if matches!( + constraint.kind, + ConstraintKind::Perpendicular | ConstraintKind::Tangent + ) { + if let [first, second, ..] = constraint.refs.as_slice() { + if let (Some(first_curve), Some(second_curve)) = ( + constraint_reference_curve_xy(document, *first), + constraint_reference_curve_xy(document, *second), + ) { + let elevation = glyph_placement(document, constraint) + .map(|(anchor, _)| anchor.z) + .unwrap_or(0.0); + for crossing in cadkernel::geom2d::intersect( + &first_curve, + &second_curve, + cadkernel::geom2d::Tolerance::default(), + ) { + push_unique( + &mut points, + Vector3::new(crossing.point[0], crossing.point[1], elevation), + ); + } + } + } + } + + points +} + impl super::Scene { pub fn is_parametric_constraint_visible( &self, @@ -964,8 +1081,9 @@ impl super::Scene { } /// Screen-projected parametric-constraint glyph placements for `scope`: - /// `(id, anchor, outward_screen_direction, label, is_conflicting)` for - /// every enabled, visible constraint whose glyph projects on-screen. + /// `(id, anchor, outward_screen_direction, label, is_conflicting, + /// hover_points)` for every enabled, visible constraint whose glyph + /// projects on-screen. /// `vp_size` is the full canvas size (as `SelectionState::vp_size` /// reports it), matching what `viewport_edit_frame`/ /// `active_model_tile_bounds` expect. Mirrors the projection @@ -980,7 +1098,14 @@ impl super::Scene { vp_size: (f32, f32), show_values: bool, display_mode: i16, - ) -> Vec<(ConstraintId, iced::Point, [f32; 2], String, bool)> { + ) -> Vec<( + ConstraintId, + iced::Point, + [f32; 2], + String, + bool, + Vec, + )> { let Some(set) = self.parametric_constraint_set(scope) else { return Vec::new(); }; @@ -1034,7 +1159,30 @@ impl super::Scene { } else { c.kind.glyph_symbol().to_string() }; - point.x.is_finite().then_some((c.id, point, direction.to_array(), label, is_conflicting)) + let hover_points = constraint_hover_points(&self.document, c) + .into_iter() + .filter_map(|hover_point| { + let projected = crate::scene::pick::grip::project_rte( + glam::DVec3::new(hover_point.x, hover_point.y, hover_point.z), + view_rot, + eye, + bounds, + )?; + let point = iced::Point::new( + bounds.x + projected.x, + bounds.y + projected.y, + ); + (point.x.is_finite() && point.y.is_finite()).then_some(point) + }) + .collect(); + point.x.is_finite().then_some(( + c.id, + point, + direction.to_array(), + label, + is_conflicting, + hover_points, + )) }) .collect() } @@ -1060,7 +1208,7 @@ impl super::Scene { ); let glyphs: Vec<(iced::Point, [f32; 2], String, bool)> = placements .iter() - .map(|(_, point, direction, label, is_conflicting)| { + .map(|(_, point, direction, label, is_conflicting, _)| { (*point, *direction, label.clone(), *is_conflicting) }) .collect(); diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 4e9b2fab7..40bde4606 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -27,6 +27,7 @@ const CONSTRAINT_GLYPH_PAD_X: f32 = 7.0; const CONSTRAINT_GLYPH_PAD_Y: f32 = 4.0; const CONSTRAINT_GLYPH_GAP: f32 = 6.0; const CONSTRAINT_GLYPH_ROW_GAP: f32 = 4.0; +const CONSTRAINT_HOVER_MARKER_RADIUS: f32 = 7.0; fn constraint_glyph_size(label: &str) -> Size { let w = label.chars().count() as f32 * CONSTRAINT_GLYPH_SIZE * 0.62 @@ -749,7 +750,7 @@ pub fn selection_overlay<'a>( crosshair_bg: [f32; 4], crosshair: CrosshairOptions, selection_visual: SelectionVisualOptions, - constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool)>, + constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec)>, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -833,9 +834,9 @@ struct SelectionCanvas { crosshair: CrosshairOptions, selection_visual: SelectionVisualOptions, /// Constraint glyph anchor, outward screen direction, label, conflict - /// state, and whether the pill itself is the current click-to-select - /// target (`Scene::selected_constraint`) — for the current scope. - constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool)>, + /// state, whether the pill itself is the current click-to-select target, + /// and the points to mark while the pill is hovered. + constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec)>, } fn draw_grip_marker( @@ -1021,7 +1022,7 @@ impl canvas::Program for SelectionCanvas { let glyphs: Vec<_> = self .constraint_glyphs .iter() - .map(|(anchor, outward, label, conflict, _)| { + .map(|(anchor, outward, label, conflict, _, _)| { (*anchor, *outward, label.clone(), *conflict) }) .collect(); @@ -1747,15 +1748,18 @@ impl canvas::Program for SelectionCanvas { if !self.constraint_glyphs.is_empty() { // `constraint_glyph_offsets` only needs the anchor/outward/label/ // conflict quadruple it was written against; project away the - // trailing `is_selected` flag rather than widen its signature. + // trailing display fields rather than widen its signature. let glyphs_for_offsets: Vec<(Point, [f32; 2], String, bool)> = self .constraint_glyphs .iter() - .map(|(anchor, outward, label, is_conflicting, _)| { + .map(|(anchor, outward, label, is_conflicting, _, _)| { (*anchor, *outward, label.clone(), *is_conflicting) }) .collect(); let offsets = constraint_glyph_offsets(&glyphs_for_offsets); + let hovered = cursor + .position_in(bounds) + .and_then(|point| constraint_glyph_hit_test(&glyphs_for_offsets, point)); let normal_bg = theme.palette().primary.base.color; let normal_fg = theme.palette().primary.base.text; // A redundant or conflicting constraint gets the danger palette @@ -1765,7 +1769,7 @@ impl canvas::Program for SelectionCanvas { let conflict_bg = theme.palette().danger.base.color; let conflict_fg = theme.palette().danger.base.text; let selected_ring = theme.palette().primary.strong.color; - for ((anchor, outward, label, is_conflicting, is_selected), tangent_offset) in + for ((anchor, outward, label, is_conflicting, is_selected, _), tangent_offset) in self.constraint_glyphs.iter().zip(offsets) { if !anchor.x.is_finite() || !anchor.y.is_finite() { @@ -1800,6 +1804,34 @@ impl canvas::Program for SelectionCanvas { ..Default::default() }); } + if let Some(index) = hovered { + let red = Color::from_rgb(1.0, 0.0, 0.0); + let stroke = canvas::Stroke::default().with_color(red).with_width(1.5); + for point in &self.constraint_glyphs[index].5 { + let first = canvas::Path::line( + Point::new( + point.x - CONSTRAINT_HOVER_MARKER_RADIUS, + point.y - CONSTRAINT_HOVER_MARKER_RADIUS, + ), + Point::new( + point.x + CONSTRAINT_HOVER_MARKER_RADIUS, + point.y + CONSTRAINT_HOVER_MARKER_RADIUS, + ), + ); + let second = canvas::Path::line( + Point::new( + point.x - CONSTRAINT_HOVER_MARKER_RADIUS, + point.y + CONSTRAINT_HOVER_MARKER_RADIUS, + ), + Point::new( + point.x + CONSTRAINT_HOVER_MARKER_RADIUS, + point.y - CONSTRAINT_HOVER_MARKER_RADIUS, + ), + ); + frame.stroke(&first, stroke.clone()); + frame.stroke(&second, stroke.clone()); + } + } } // Small cross at each acquired tracking point. for ost in &self.ost_points { From ba2b3712696f91e17670ca9aaebd82be5e2dae0e Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:00:26 +0300 Subject: [PATCH 24/33] Complete Coincident command behavior --- src/app/command_driver.rs | 220 ++++++++++++--- src/app/commands/draw.rs | 9 +- src/app/properties.rs | 82 +----- src/command.rs | 30 +- src/entities/line.rs | 16 ++ src/modules/parametric/coincident.rs | 275 +++++++++++++++++-- src/modules/parametric/geom_constraint.rs | 87 ++++++ src/modules/parametric/mod.rs | 2 + src/scene/cache/properties.rs | 11 +- src/scene/mod.rs | 10 +- src/scene/parametric_constraints.rs | 309 ++++++++++++++++++++- src/scene/parametric_solve.rs | 320 +++++++++++++++++++--- src/scene/view/dispatch.rs | 19 ++ 13 files changed, 1183 insertions(+), 207 deletions(-) create mode 100644 src/modules/parametric/geom_constraint.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 5546cddf8..59a54b549 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -2082,10 +2082,17 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; return Task::none(); } + if matches!(&transform, crate::command::EntityTransform::Translate(_)) { + let scope = self.tabs[i].current_parametric_scope(); + handles = self.tabs[i] + .scene + .parametric_connected_handles(scope, &handles); + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + } let label = self.history_label_from_active_cmd(i, "MOVE"); - // A move/rotate/scale/mirror mutates only the selected entities - // (and their baked dimension sub-entities) through - // transform_entities — always delta-safe. + // A translation carries its complete connected constraint + // component so each member keeps its own dimensions. Other + // transforms retain their selected-object behavior. let pending = self.begin_undo(i, label, handles.len(), true); self.tabs[i].scene.transform_entities(&handles, &transform); self.tabs[i].dirty = true; @@ -2955,44 +2962,165 @@ impl OpenCADStudio { } } CmdResult::AddCoincidentConstraint { - point_a, - point_b, + first, + second, + multiple, label, } => { let scope = self.tabs[i].current_parametric_scope(); let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); - let resolved_a = crate::scene::parametric_constraints::nearest_parametric_point( - &self.tabs[i].scene.document, - scope, - to_world(point_a), - None, - ); - let resolved_b = resolved_a.and_then(|ref_a| { - crate::scene::parametric_constraints::nearest_parametric_point( - &self.tabs[i].scene.document, - scope, - to_world(point_b), - None, - ) - .filter(|ref_b| *ref_b != ref_a) - }); - match (resolved_a, resolved_b) { - (Some(ref_a), Some(ref_b)) => { - return self.apply_cmd_result(CmdResult::AddParametricConstraint { - kind: crate::scene::parametric_constraints::ConstraintKind::Coincident, - refs: vec![ref_a, ref_b], - driving_param: None, - label, - }); + let resolve = |pick: crate::command::CoincidentPick, + exclude: Option| { + if pick.whole_curve { + pick.handle.and_then(|handle| { + crate::scene::parametric_constraints::parametric_curve_ref_for_pick( + &self.tabs[i].scene.document, + scope, + handle, + to_world(pick.point), + ) + }) + } else if let Some(handle) = pick.handle { + crate::scene::parametric_constraints::nearest_parametric_point_on_entity( + &self.tabs[i].scene.document, + scope, + handle, + to_world(pick.point), + ) + } else { + crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(pick.point), + exclude, + ) } + }; + let resolved_first = resolve(first, None); + let resolved_second = resolved_first + .and_then(|reference| resolve(second, Some(reference.entity))); + let (Some(first_ref), Some(second_ref)) = (resolved_first, resolved_second) else { + self.command_line.push_error( + "Coincident: select a supported endpoint, center, midpoint, vertex, or curve.", + ); + return Task::none(); + }; + let (kind, refs) = match (first.whole_curve, second.whole_curve) { + (false, false) if first_ref != second_ref => ( + crate::scene::parametric_constraints::ConstraintKind::Coincident, + vec![first_ref, second_ref], + ), + (true, false) => ( + crate::scene::parametric_constraints::ConstraintKind::PointOnCurve, + vec![second_ref, first_ref], + ), + (false, true) => ( + crate::scene::parametric_constraints::ConstraintKind::PointOnCurve, + vec![first_ref, second_ref], + ), _ => { - self.command_line.push_error( - "Coincident: pick didn't land on a point (endpoint, center, or vertex) — enable an Endpoint/Center object snap and try again.", - ); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; + self.command_line + .push_error("Coincident: select one point and one curve, or two points."); + return Task::none(); + } + }; + if let Err(error) = self.tabs[i] + .scene + .validate_parametric_constraint(kind, &refs, None) + { + self.command_line.push_error(error); + return Task::none(); + } + let touched: Vec<_> = refs.iter().map(|reference| reference.entity).collect(); + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(kind, refs, None); + let changes: Vec<_> = touched + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i] + .scene + .bump_entities_with_parametric_driven(&changes, &[first_ref]); + self.tabs[i].dirty = true; + self.tabs[i].snap_result = None; + if !multiple { + self.tabs[i].active_cmd = None; + } + self.command_line.push_output("Coincident constraint applied."); + if multiple { + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|cmd| cmd.prompt()) { + self.command_line.push_info(&prompt); + } + self.command_line.set_step_options( + self.tabs[i] + .active_cmd + .as_ref() + .map(|cmd| cmd.options()) + .unwrap_or_default(), + ); + } + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + CmdResult::AddAutoCoincidentConstraints { handles } => { + use crate::scene::parametric_constraints::ConstraintKind; + let scope = self.tabs[i].current_parametric_scope(); + let inferred = self.tabs[i] + .scene + .inferred_coincident_constraints(scope, &handles); + let count = inferred.len(); + if count > 0 { + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new( + scope, + ) + }); + let pending = self.begin_undo(i, "Coincident auto constrain", handles.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + for refs in inferred { + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(ConstraintKind::Coincident, refs, None); + } + let changes: Vec<_> = handles + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); } } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_output(format!("{} coincident constraint(s) applied.", count).as_str()); } CmdResult::AddPointOnEntityConstraint { point, @@ -8276,8 +8404,17 @@ mod parametric_constraint_undo_tests { let b = add_line(&mut app, 20.0, 0.0, 20.0, 5.0); // deliberately far from `a`, so the two picks are unambiguous let _ = app.apply_cmd_result(CmdResult::AddCoincidentConstraint { - point_a: glam::DVec3::new(5.0, 0.0, 0.0), // a's end - point_b: glam::DVec3::new(20.0, 0.0, 0.0), // b's start + first: crate::command::CoincidentPick { + handle: Some(a), + point: glam::DVec3::new(5.0, 0.0, 0.0), + whole_curve: false, + }, + second: crate::command::CoincidentPick { + handle: Some(b), + point: glam::DVec3::new(20.0, 0.0, 0.0), + whole_curve: false, + }, + multiple: false, label: "Coincident constraint", }); @@ -8305,8 +8442,17 @@ mod parametric_constraint_undo_tests { let _a = add_line(&mut app, 0.0, 0.0, 5.0, 0.0); let _ = app.apply_cmd_result(CmdResult::AddCoincidentConstraint { - point_a: glam::DVec3::new(5.0, 0.0, 0.0), // a's end — a real point - point_b: glam::DVec3::new(500.0, 500.0, 0.0), // nowhere near anything + first: crate::command::CoincidentPick { + handle: Some(_a), + point: glam::DVec3::new(5.0, 0.0, 0.0), + whole_curve: false, + }, + second: crate::command::CoincidentPick { + handle: None, + point: glam::DVec3::new(500.0, 500.0, 0.0), + whole_curve: false, + }, + multiple: false, label: "Coincident constraint", }); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index e45c8c1a4..05769fbcb 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1300,13 +1300,20 @@ impl OpenCADStudio { } } - "CCONSTRAINT" => { + "CCONSTRAINT" | "GCCOINCIDENT" => { use crate::modules::parametric::CoincidentConstraintCommand; let new_cmd = CoincidentConstraintCommand::new(); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "GEOMCONSTRAINT" => { + use crate::modules::parametric::GeomConstraintCommand; + let new_cmd = GeomConstraintCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + "EDCONSTRAINT" => { use crate::modules::parametric::EqualDistanceConstraintCommand; let new_cmd = EqualDistanceConstraintCommand::new(); diff --git a/src/app/properties.rs b/src/app/properties.rs index f31741f8b..50c485ab9 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -882,12 +882,21 @@ impl OpenCADStudio { } if !compact_solid { - sections.extend(crate::entities::object_data::sections( + let mut object_sections = crate::entities::object_data::sections( &self.tabs[i].scene.document, &self.tabs[i].scene.object_data_cache, handle, entity, - )); + ); + let scope = self.tabs[i].current_parametric_scope(); + let constrained = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| set.constraints_touching(handle).next().is_some()); + if constrained { + object_sections.retain(|section| section.title != "Associative Data"); + } + sections.extend(object_sections); } { @@ -2225,75 +2234,6 @@ impl OpenCADStudio { if compact_solid { retain_compact_solid_sections(&mut sections); } - // Parametric-constraint list for this entity — - // the Properties-panel analogue of the viewport's - // constraint glyph pills: one row per constraint - // touching `handle`, clickable to select every entity - // it relates (PropConstraintLinkClick). Omitted - // entirely when the entity has none — an unconstrained - // entity (the common case) shouldn't gain empty - // bookkeeping. - { - let scope = self.tabs[i].current_parametric_scope(); - let scene = &self.tabs[i].scene; - if let Some(set) = scene.parametric_constraint_set(scope) { - let parameters = if set.local_parameters.is_empty() { - scene.named_parameters() - } else { - &set.local_parameters - }; - let props: Vec = set - .constraints_touching(handle) - .map(|c| { - let conflicting = - set.conflicts.iter().any(|(id, _)| *id == c.id); - let value_suffix = match &c.driving_param { - Some( - crate::scene::named_parameters::DrivingValue::Literal( - v, - ), - ) => { - format!(" = {v:.2}") - } - Some( - crate::scene::named_parameters::DrivingValue::Named( - name, - ), - ) => match parameters.resolve(name) { - Ok(v) => format!(" = {name} ({v:.2})"), - Err(_) => format!(" = {name} (?)"), - }, - None => String::new(), - }; - let label = format!( - "{} {:?}{}", - c.kind.glyph_symbol(), - c.kind, - value_suffix - ); - let mut handles: Vec = - c.refs.iter().map(|r| r.entity).collect(); - handles.sort_by_key(|h| h.value()); - handles.dedup(); - crate::scene::model::object::Property { - label, - field: "parametric_constraint", - value: crate::scene::model::object::PropValue::EntityLink { - id: c.id, - handles, - conflicting, - }, - } - }) - .collect(); - if !props.is_empty() { - sections.push(crate::scene::model::object::PropSection { - title: t!("Constraints").into_owned(), - props, - }); - } - } - } let title = match entity { acadrust::EntityType::Insert(ins) => { let is_xref = self.tabs[i] diff --git a/src/command.rs b/src/command.rs index b4e9da1df..b01e58558 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1237,6 +1237,17 @@ pub enum ExtrudeExtent { Path(Handle), } +/// One ordered selection used by the Coincident command. A click can name +/// either a constraint point on an entity or the entity's whole curve. Typed +/// coordinates have no entity handle and are resolved by the host after the +/// command returns. +#[derive(Clone, Copy, Debug)] +pub struct CoincidentPick { + pub handle: Option, + pub point: DVec3, + pub whole_curve: bool, +} + /// Construction options shared by SWEEP creation and its live preview. #[derive(Clone, Copy, Debug, PartialEq)] pub struct SweepOptions { @@ -1466,14 +1477,21 @@ pub enum CmdResult { /// Undo-history label, e.g. `"Horizontal constraint"`. label: &'static str, }, - /// Adds a Coincident constraint between the sub-entity points nearest - /// the two picked positions. The host resolves the raw points because - /// `CadCommand` has no document access. + /// Adds an ordered Coincident relation. Point/point selections create a + /// Coincident constraint; point/curve selections create the corresponding + /// point-on-curve relation while retaining Coincident command semantics. AddCoincidentConstraint { - point_a: DVec3, - point_b: DVec3, + first: CoincidentPick, + second: CoincidentPick, + /// Keep the first curve active and accept another point. + multiple: bool, label: &'static str, }, + /// Apply only Coincident relations that already exist geometrically in + /// the selected set. + AddAutoCoincidentConstraints { + handles: Vec, + }, /// Add a persistent `CenterPoint`/`Midpoint`/`PointOnCurve` constraint /// (`crate::modules::parametric::point_on_entity`) between one /// picked point and `target`, a whole entity selected before the tool @@ -2681,6 +2699,8 @@ mod constraint_registry_tests { let names = all_registered_command_names(); for id in [ "CCONSTRAINT", + "GCCOINCIDENT", + "GEOMCONSTRAINT", "EDCONSTRAINT", "CPCONSTRAINT", "MPCONSTRAINT", diff --git a/src/entities/line.rs b/src/entities/line.rs index 0905194d8..0f4e9fdfb 100644 --- a/src/entities/line.rs +++ b/src/entities/line.rs @@ -214,6 +214,9 @@ fn properties(line: &Line) -> Vec { ro(t!("Delta Z").as_ref(), "delta_z", format!("{dz:.4}")), ro(t!("Length").as_ref(), "length", format!("{:.4}", line.length())), ro(t!("Angle").as_ref(), "angle", format!("{angle:.2}")), + edit(t!("Normal X").as_ref(), "normal_x", line.normal.x), + edit(t!("Normal Y").as_ref(), "normal_y", line.normal.y), + edit(t!("Normal Z").as_ref(), "normal_z", line.normal.z), ], }] } @@ -286,6 +289,19 @@ fn apply_geom_prop(line: &mut Line, field: &str, value: &str) { "end_x" => line.end.x = v, "end_y" => line.end.y = v, "end_z" => line.end.z = v, + "normal_x" | "normal_y" | "normal_z" => { + let mut normal = glam::DVec3::new(line.normal.x, line.normal.y, line.normal.z); + match field { + "normal_x" => normal.x = v, + "normal_y" => normal.y = v, + "normal_z" => normal.z = v, + _ => unreachable!(), + } + if normal.is_finite() && normal.length_squared() > 1.0e-18 { + let normal = normal.normalize(); + line.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z); + } + } _ => {} } } diff --git a/src/modules/parametric/coincident.rs b/src/modules/parametric/coincident.rs index 356c6b556..cb4f3b7a6 100644 --- a/src/modules/parametric/coincident.rs +++ b/src/modules/parametric/coincident.rs @@ -1,20 +1,10 @@ -//! CCONSTRAINT — manual Coincident constraint input. -//! unlike the rest of this module's plain select-and-click constraints, -//! Coincident addresses a *point* on an entity (an endpoint, a circle/arc -//! center), not the whole entity, so it needs two point picks instead of a -//! selection. -//! -//! A `CadCommand` has no document access (see `value.rs`'s module doc -//! comment for the same constraint on `DCONSTRAINT`/`ACONSTRAINT`), so this -//! command only accumulates the two raw points `on_point` receives and hands -//! them back as `CmdResult::AddCoincidentConstraint` — the host resolves -//! each point to a real `ParametricRef` (`parametric_constraints::nearest_parametric_point`) -//! and adds the constraint, the same split `ReassociateCenterMark` already -//! uses for a similar reason. +//! Interactive Coincident input: ordered point/curve picks, repeated +//! point-to-curve placement, and Coincident-only automatic inference. +use acadrust::{EntityType, Handle}; use glam::DVec3; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdOption, CmdResult, CoincidentPick}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; pub mod coincident_tool { @@ -31,45 +21,272 @@ pub mod coincident_tool { } } -#[derive(Default)] +#[derive(Clone)] +enum Step { + First, + FirstObject, + Second(CoincidentPick), + PointsOnCurve { + curve: CoincidentPick, + multiple: bool, + }, + AutoConstrain { + handles: Vec, + }, +} + pub struct CoincidentConstraintCommand { - first: Option, + step: Step, + picked_entity: Option, } impl CoincidentConstraintCommand { pub fn new() -> Self { - Self::default() + Self { + step: Step::First, + picked_entity: None, + } + } + + fn point_pick(handle: Option, point: DVec3) -> CoincidentPick { + CoincidentPick { + handle, + point, + whole_curve: false, + } + } + + fn curve_pick(handle: Handle, point: DVec3) -> CoincidentPick { + CoincidentPick { + handle: Some(handle), + point, + whole_curve: true, + } + } + + fn picked_constraint_point(entity: &EntityType, point: DVec3) -> bool { + crate::scene::parametric_constraints::is_parametric_point_near( + entity, + acadrust::types::Vector3::new(point.x, point.y, point.z), + ) + } + + fn valid_curve(entity: &EntityType) -> bool { + matches!( + entity, + EntityType::Line(_) + | EntityType::LwPolyline(_) + | EntityType::Polyline2D(_) + | EntityType::Circle(_) + | EntityType::Arc(_) + | EntityType::Ellipse(_) + | EntityType::Spline(_) + ) } } impl CadCommand for CoincidentConstraintCommand { fn name(&self) -> &'static str { - "CCONSTRAINT" + "GCCOINCIDENT" } fn prompt(&self) -> String { - if self.first.is_none() { - "COINCIDENT Specify first point:".to_string() - } else { - "COINCIDENT Specify second point:".to_string() + match self.step.clone() { + Step::First => { + "COINCIDENT Select first point or [Object/Autoconstrain] :".to_string() + } + Step::FirstObject => "COINCIDENT Select object:".to_string(), + Step::Second(_) => "COINCIDENT Select second point or object:".to_string(), + Step::PointsOnCurve { multiple: false, .. } => { + "COINCIDENT Select point or [Multiple]:".to_string() + } + Step::PointsOnCurve { multiple: true, .. } => { + "COINCIDENT Select points to coincide with the first object (Enter = done):" + .to_string() + } + Step::AutoConstrain { handles } if handles.is_empty() => { + "COINCIDENT Select objects:".to_string() + } + Step::AutoConstrain { handles } => format!( + "COINCIDENT Select objects ({} selected, Enter = apply):", + handles.len() + ), } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - match self.first { - None => { - self.first = Some(pt); + match &self.step { + Step::First => { + self.step = Step::Second(Self::point_pick(None, pt)); CmdResult::NeedPoint } - Some(first) => CmdResult::AddCoincidentConstraint { - point_a: first, - point_b: pt, + Step::FirstObject => CmdResult::NeedPoint, + Step::Second(first) => CmdResult::AddCoincidentConstraint { + first: *first, + second: Self::point_pick(None, pt), + multiple: false, label: "Coincident constraint", }, + Step::PointsOnCurve { curve, multiple } => CmdResult::AddCoincidentConstraint { + first: *curve, + second: Self::point_pick(None, pt), + multiple: *multiple, + label: "Coincident constraint", + }, + Step::AutoConstrain { .. } => CmdResult::NeedPoint, } } + fn options(&self) -> Vec { + match self.step.clone() { + Step::First => vec![ + CmdOption::new("Object", "O"), + CmdOption::new("Autoconstrain", "A"), + ], + Step::PointsOnCurve { multiple: false, .. } => vec![ + CmdOption::new("Point", "P"), + CmdOption::new("Multiple", "M"), + ], + Step::PointsOnCurve { multiple: true, .. } => vec![CmdOption::enter("Done")], + _ => Vec::new(), + } + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + !matches!(&self.step, Step::AutoConstrain { .. }) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text + .trim() + .trim_start_matches('_') + .to_ascii_uppercase(); + match (&mut self.step, keyword.as_str()) { + (Step::First, "O" | "OBJECT") => { + self.step = Step::FirstObject; + Some(CmdResult::NeedPoint) + } + (Step::First, "A" | "AUTOCONSTRAIN") => { + self.step = Step::AutoConstrain { handles: Vec::new() }; + Some(CmdResult::NeedPoint) + } + (Step::PointsOnCurve { multiple, .. }, "M" | "MULTIPLE") => { + *multiple = true; + Some(CmdResult::NeedPoint) + } + (Step::PointsOnCurve { .. }, "P" | "POINT") => Some(CmdResult::NeedPoint), + _ => None, + } + } + + fn needs_entity_pick(&self) -> bool { + !matches!(&self.step, Step::AutoConstrain { .. }) + } + + fn entity_pick_accepts_points(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + let is_point = self + .picked_entity + .as_ref() + .is_some_and(|entity| Self::picked_constraint_point(entity, point)); + let is_curve = self + .picked_entity + .as_ref() + .is_some_and(Self::valid_curve); + self.picked_entity = None; + + match self.step.clone() { + Step::FirstObject if is_curve => { + self.step = Step::PointsOnCurve { + curve: Self::curve_pick(handle, point), + multiple: false, + }; + CmdResult::NeedPoint + } + Step::First if is_point => { + self.step = Step::Second(Self::point_pick(Some(handle), point)); + CmdResult::NeedPoint + } + Step::First if is_curve => { + self.step = Step::PointsOnCurve { + curve: Self::curve_pick(handle, point), + multiple: false, + }; + CmdResult::NeedPoint + } + Step::Second(first) if is_point => CmdResult::AddCoincidentConstraint { + first, + second: Self::point_pick(Some(handle), point), + multiple: false, + label: "Coincident constraint", + }, + Step::Second(first) if is_curve => CmdResult::AddCoincidentConstraint { + first, + second: Self::curve_pick(handle, point), + multiple: false, + label: "Coincident constraint", + }, + Step::PointsOnCurve { curve, multiple } if is_point => { + CmdResult::AddCoincidentConstraint { + first: curve, + second: Self::point_pick(Some(handle), point), + multiple, + label: "Coincident constraint", + } + } + _ => CmdResult::NeedPoint, + } + } + + fn is_selection_gathering(&self) -> bool { + matches!(&self.step, Step::AutoConstrain { .. }) + } + + fn selection_forces_add(&self) -> bool { + true + } + + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + if let Step::AutoConstrain { handles: selected } = &mut self.step { + *selected = handles; + } + CmdResult::NeedPoint + } + fn on_enter(&mut self) -> CmdResult { + if matches!(&self.step, Step::First) { + self.step = Step::FirstObject; + return CmdResult::NeedPoint; + } + if let Step::AutoConstrain { handles } = &self.step { + if !handles.is_empty() { + return CmdResult::AddAutoCoincidentConstraints { + handles: handles.clone(), + }; + } + } CmdResult::Cancel } @@ -80,5 +297,5 @@ impl CadCommand for CoincidentConstraintCommand { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { - names: &["CCONSTRAINT"] + names: &["CCONSTRAINT", "GCCOINCIDENT"] }); diff --git a/src/modules/parametric/geom_constraint.rs b/src/modules/parametric/geom_constraint.rs new file mode 100644 index 000000000..6bf27ddde --- /dev/null +++ b/src/modules/parametric/geom_constraint.rs @@ -0,0 +1,87 @@ +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult}; + +/// Command-line front end for the standard geometric-constraint family. +/// Each choice is handed to the existing focused command so ribbon and typed +/// entry use one implementation. +pub struct GeomConstraintCommand; + +impl GeomConstraintCommand { + pub fn new() -> Self { + Self + } + + fn dispatch(keyword: &str) -> Option<&'static str> { + Some(match keyword { + "H" | "HORIZONTAL" => "HCONSTRAINT", + "V" | "VERTICAL" => "VCONSTRAINT", + "P" | "PERPENDICULAR" => "QCONSTRAINT", + "PA" | "PARALLEL" => "PCONSTRAINT", + "T" | "TANGENT" => "TCONSTRAINT", + "SM" | "SMOOTH" => "SMOOTHCONSTRAINT", + "C" | "COINCIDENT" => "GCCOINCIDENT", + "CON" | "CONCENTRIC" => "NCONSTRAINT", + "COL" | "COLLINEAR" | "COLINEAR" => "LCONSTRAINT", + "SY" | "SYMMETRIC" => "SYCONSTRAINT", + "E" | "EQUAL" => "ECONSTRAINT", + "F" | "FIX" => "FXCONSTRAINT", + _ => return None, + }) + } +} + +impl CadCommand for GeomConstraintCommand { + fn name(&self) -> &'static str { + "GEOMCONSTRAINT" + } + + fn prompt(&self) -> String { + "GEOMCONSTRAINT Enter constraint type [Horizontal/Vertical/Perpendicular/Parallel/Tangent/Smooth/Coincident/Concentric/Collinear/Symmetric/Equal/Fix] :".to_string() + } + + fn options(&self) -> Vec { + vec![ + CmdOption::new("Horizontal", "H"), + CmdOption::new("Vertical", "V"), + CmdOption::new("Perpendicular", "P"), + CmdOption::new("Parallel", "PA"), + CmdOption::new("Tangent", "T"), + CmdOption::new("Smooth", "SM"), + CmdOption::new("Coincident", "C"), + CmdOption::new("Concentric", "CON"), + CmdOption::new("Collinear", "COL"), + CmdOption::new("Symmetric", "SY"), + CmdOption::new("Equal", "E"), + CmdOption::new("Fix", "F"), + ] + } + + fn wants_text_input(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text + .trim() + .trim_start_matches('_') + .to_ascii_uppercase(); + Self::dispatch(&keyword).map(|command| CmdResult::Dispatch(command.to_string())) + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Dispatch("GCCOINCIDENT".to_string()) + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["GEOMCONSTRAINT"] +}); diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 41efdbf9e..07dcde806 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -4,11 +4,13 @@ mod coincident; mod equal_distance; +mod geom_constraint; mod point_on_entity; mod tools; mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; +pub use geom_constraint::GeomConstraintCommand; pub use point_on_entity::{ center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand, }; diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index 4a08c1571..4490e24ea 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -32,7 +32,7 @@ pub fn general_section(entity: &EntityType) -> PropSection { let hyperlink = crate::scene::pe_url_of(entity).unwrap_or_default().to_owned(); - let mut section = PropSection { + let section = PropSection { title: t!("General").into_owned(), props: vec![ Property { @@ -88,15 +88,6 @@ pub fn general_section(entity: &EntityType) -> PropSection { ], }; - // Thickness (DXF 39) is a General-group property, but only the entity - // types that carry an extrusion thickness expose it (line, circle, arc, - // polyline, text, 2D solid, …). Show it right after Hyperlink for those. - if let Some(t) = crate::scene::view::dispatch::entity_thickness(entity) { - section - .props - .push(crate::entities::common::edit_prop(t!("Thickness").as_ref(), "thickness", t)); - } - section } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 6269866ce..f6536d716 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2836,6 +2836,14 @@ impl Scene { } pub fn bump_entities(&mut self, changes: &[(Handle, ChangeKind)]) { + self.bump_entities_with_parametric_driven(changes, &[]); + } + + pub fn bump_entities_with_parametric_driven( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + ) { if changes.iter().any(|(handle, kind)| { matches!(kind, ChangeKind::Removed) || self @@ -2869,7 +2877,7 @@ impl Scene { } } if !self.parametric_constraints.is_empty() { - for change in self.refresh_parametric_constraints(&changes) { + for change in self.refresh_parametric_constraints_with_driven(&changes, driven_refs) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 387dbaac6..6cbd648a6 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -361,6 +361,7 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio return match entity { acadrust::EntityType::Circle(circle) => Some(circle.center_wcs()), acadrust::EntityType::Arc(arc) => Some(arc.center_wcs()), + acadrust::EntityType::Ellipse(ellipse) => Some(ellipse.center), _ => None, }; } @@ -392,6 +393,23 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio } else { return None; }; + let bulge = match entity { + acadrust::EntityType::LwPolyline(polyline) => { + polyline.vertices.get(segment)?.bulge + } + acadrust::EntityType::Polyline2D(polyline) => { + polyline.vertices.get(segment)?.bulge + } + _ => 0.0, + }; + if let Some(arc) = cadkernel::geom2d::BulgeArc::from_bulge( + [a.x, a.y], + [b.x, b.y], + bulge, + ) { + let midpoint = arc.sample(0.5); + return Some(Vector3::new(midpoint[0], midpoint[1], (a.z + b.z) * 0.5)); + } return Some((a + b) * 0.5); } if marker < 0 { @@ -406,6 +424,97 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio /// already coincident for [`nearest_parametric_point`]'s purposes. const COINCIDENT_EPSILON_SQ: f64 = 1.0e-12; +/// Addressable constraint points for one entity, in the same marker space +/// used by persistent constraint references. +pub(crate) fn parametric_point_candidates( + entity: &acadrust::EntityType, +) -> Vec<(i32, Vector3)> { + let mut points: Vec<_> = super::dimension_assoc::source_points(entity) + .into_iter() + .enumerate() + .map(|(marker, point)| (marker as i32, point)) + .collect(); + match entity { + acadrust::EntityType::Circle(circle) => points.push((-3, circle.center_wcs())), + acadrust::EntityType::Arc(arc) => points.push((-3, arc.center_wcs())), + acadrust::EntityType::Ellipse(ellipse) => points.push((-3, ellipse.center)), + _ => {} + } + + if matches!( + entity, + acadrust::EntityType::Line(_) + | acadrust::EntityType::Arc(_) + | acadrust::EntityType::Spline(_) + | acadrust::EntityType::Ellipse(_) + ) { + if let Some(curve) = crate::entities::curve::entity_curve(entity) { + if !curve.is_closed() { + let point = curve.point_at(0.5); + points.push((-2, Vector3::new(point[0], point[1], point[2]))); + } + } + } + + let polyline_midpoints = |vertices: &[Vector3], bulges: &[f64], closed: bool| { + let mut result = Vec::new(); + for (index, bulge) in bulges.iter().copied().enumerate() { + let Some(a) = vertices.get(index).copied() else { + continue; + }; + let b = vertices + .get(index + 1) + .copied() + .or_else(|| closed.then(|| vertices.first().copied()).flatten()); + if let Some(b) = b { + let midpoint = cadkernel::geom2d::BulgeArc::from_bulge( + [a.x, a.y], + [b.x, b.y], + bulge, + ) + .map(|arc| { + let point = arc.sample(0.5); + Vector3::new(point[0], point[1], (a.z + b.z) * 0.5) + }) + .unwrap_or((a + b) * 0.5); + result.push(( + POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - index as i32, + midpoint, + )); + } + } + result + }; + match entity { + acadrust::EntityType::LwPolyline(polyline) => { + let vertices = super::dimension_assoc::source_points(entity); + let bulges: Vec<_> = polyline + .vertices + .iter() + .map(|vertex| vertex.bulge) + .collect(); + points.extend(polyline_midpoints(&vertices, &bulges, polyline.is_closed)); + } + acadrust::EntityType::Polyline2D(polyline) => { + let vertices = super::dimension_assoc::source_points(entity); + let bulges: Vec<_> = polyline + .vertices + .iter() + .map(|vertex| vertex.bulge) + .collect(); + points.extend(polyline_midpoints(&vertices, &bulges, polyline.is_closed())); + } + _ => {} + } + points +} + +pub(crate) fn is_parametric_point_near(entity: &acadrust::EntityType, point: Vector3) -> bool { + parametric_point_candidates(entity) + .into_iter() + .any(|(_, candidate)| (candidate - point).length_squared() <= COINCIDENT_EPSILON_SQ) +} + /// Finds the addressable entity point nearest a snapped world position. /// Returns `None` when no point in the scope is within the coincidence /// tolerance. @@ -431,21 +540,96 @@ pub(crate) fn nearest_parametric_point( if common.owner_handle != owner || Some(common.handle) == exclude { continue; } - for (marker, point) in super::dimension_assoc::source_points(candidate) - .into_iter() - .enumerate() - { - consider(common.handle, marker as i32, point); - } - match candidate { - acadrust::EntityType::Circle(c) => consider(common.handle, -3, c.center_wcs()), - acadrust::EntityType::Arc(a) => consider(common.handle, -3, a.center_wcs()), - _ => {} + for (marker, point) in parametric_point_candidates(candidate) { + consider(common.handle, marker, point); } } best.map(|(_, r)| r) } +/// Resolve a point pick within one explicitly selected entity. This keeps two +/// different endpoints at the same world coordinate distinguishable. +pub(crate) fn nearest_parametric_point_on_entity( + document: &acadrust::CadDocument, + scope: ParametricScope, + handle: Handle, + world_point: Vector3, +) -> Option { + let entity = document.get_entity(handle)?; + if entity.common().owner_handle != scope.owner_handle(document) { + return None; + } + parametric_point_candidates(entity) + .into_iter() + .filter_map(|(marker, point)| { + let distance = (point - world_point).length_squared(); + (distance <= COINCIDENT_EPSILON_SQ) + .then_some((distance, ParametricRef::point(handle, marker))) + }) + .min_by(|(a, _), (b, _)| a.total_cmp(b)) + .map(|(_, reference)| reference) +} + +fn point_segment_distance_squared(point: Vector3, start: Vector3, end: Vector3) -> f64 { + let edge = end - start; + let denominator = edge.length_squared(); + if denominator <= f64::EPSILON { + return (point - start).length_squared(); + } + let unit = ((point - start).dot(&edge) / denominator).clamp(0.0, 1.0); + (point - (start + edge * unit)).length_squared() +} + +/// Resolve the whole curve or the picked straight polyline segment used by a +/// point-to-curve Coincident relation. +pub(crate) fn parametric_curve_ref_for_pick( + document: &acadrust::CadDocument, + scope: ParametricScope, + handle: Handle, + world_point: Vector3, +) -> Option { + let entity = document.get_entity(handle)?; + if entity.common().owner_handle != scope.owner_handle(document) { + return None; + } + match entity { + acadrust::EntityType::Line(_) + | acadrust::EntityType::Circle(_) + | acadrust::EntityType::Arc(_) + | acadrust::EntityType::Ellipse(_) + | acadrust::EntityType::Spline(_) => Some(ParametricRef::whole(handle)), + acadrust::EntityType::LwPolyline(polyline) => { + let vertices = super::dimension_assoc::source_points(entity); + (0..polyline.vertices.len()) + .filter_map(|index| { + let start = vertices.get(index).copied()?; + let end = vertices + .get(index + 1) + .copied() + .or_else(|| polyline.is_closed.then(|| vertices.first().copied()).flatten())?; + Some((point_segment_distance_squared(world_point, start, end), index)) + }) + .min_by(|(a, _), (b, _)| a.total_cmp(b)) + .map(|(_, index)| ParametricRef::segment(handle, index)) + } + acadrust::EntityType::Polyline2D(polyline) => { + let vertices = super::dimension_assoc::source_points(entity); + (0..polyline.vertices.len()) + .filter_map(|index| { + let start = vertices.get(index).copied()?; + let end = vertices + .get(index + 1) + .copied() + .or_else(|| polyline.is_closed().then(|| vertices.first().copied()).flatten())?; + Some((point_segment_distance_squared(world_point, start, end), index)) + }) + .min_by(|(a, _), (b, _)| a.total_cmp(b)) + .map(|(_, index)| ParametricRef::segment(handle, index)) + } + _ => None, + } +} + impl ConstraintKind { /// The short symbol a constraint glyph shows — matches the existing /// ribbon icons (`crate::modules::parametric::{tools,value}`) for @@ -568,6 +752,49 @@ pub(crate) fn glyph_placement( } impl super::Scene { + /// Expand a set of entities to the full enabled constraint component in + /// one scope. Whole-object translations use this to keep every connected + /// entity's own shape while moving the assembly. + pub(crate) fn parametric_connected_handles( + &self, + scope: ParametricScope, + seeds: &[Handle], + ) -> Vec { + let mut ordered = seeds.to_vec(); + let mut found: std::collections::HashSet<_> = seeds.iter().copied().collect(); + let Some(set) = self.parametric_constraint_set(scope) else { + return ordered; + }; + loop { + let mut added = false; + for constraint in set.constraints.iter().filter(|constraint| { + constraint.enabled + && matches!( + constraint.kind, + ConstraintKind::Coincident | ConstraintKind::PointOnCurve + ) + }) { + if !constraint + .refs + .iter() + .any(|reference| found.contains(&reference.entity)) + { + continue; + } + for reference in &constraint.refs { + if found.insert(reference.entity) { + ordered.push(reference.entity); + added = true; + } + } + } + if !added { + break; + } + } + ordered + } + pub fn is_parametric_constraint_visible( &self, scope: ParametricScope, @@ -724,6 +951,68 @@ impl super::Scene { mapped } + /// Infer only endpoint/vertex coincidences from the selected objects. + /// Unlike the broader automatic constraint pass, this includes spline, + /// ellipse, and polyline vertices while leaving centers and midpoints to + /// their dedicated constraint kinds. + pub fn inferred_coincident_constraints( + &self, + scope: ParametricScope, + handles: &[Handle], + ) -> Vec> { + let owner = scope.owner_handle(&self.document); + let mut sources = Vec::new(); + let mut seen_handles = std::collections::HashSet::new(); + for handle in handles.iter().copied() { + if !seen_handles.insert(handle) { + continue; + } + let Some(entity) = self.document.get_entity(handle) else { + continue; + }; + if entity.common().owner_handle != owner { + continue; + } + for (marker, point) in super::dimension_assoc::source_points(entity) + .into_iter() + .enumerate() + { + sources.push((ParametricRef::point(handle, marker as i32), point)); + } + } + + let existing = self.parametric_constraint_set(scope); + let mut inferred = Vec::new(); + for first in 0..sources.len() { + for second in first + 1..sources.len() { + if sources[first].0.entity == sources[second].0.entity + || (sources[first].1 - sources[second].1).length_squared() + > COINCIDENT_EPSILON_SQ + { + continue; + } + let refs = vec![sources[first].0, sources[second].0]; + let already_exists = existing.is_some_and(|set| { + set.constraints.iter().any(|constraint| { + constraint.kind == ConstraintKind::Coincident + && constraint.refs.len() == 2 + && (constraint.refs == refs + || (constraint.refs[0] == refs[1] + && constraint.refs[1] == refs[0])) + }) + }); + if !already_exists + && self + .validate_parametric_constraint(ConstraintKind::Coincident, &refs, None) + .is_ok() + { + inferred.push(refs); + } + } + } + inferred + } + pub fn smooth_constraint_refs(&self, handles: &[Handle]) -> Option> { let [first, second] = handles else { return None; diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 20e9581bb..a92273eb0 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -45,6 +45,7 @@ enum EntityGeom { Polyline { points: Rc>, straight: Rc>, + arcs: Rc>>, closed: bool, }, Circle(GCircle), @@ -61,6 +62,12 @@ enum EntityGeom { }, } +#[derive(Clone, Copy)] +struct PolylineArc { + arc: GArc, + sweep: f64, +} + impl EntityGeom { /// The `cadkernel_constraints::geo::Point` for a marker on this entity — `None` for /// a marker this entity type/value doesn't support (see @@ -91,6 +98,7 @@ impl EntityGeom { points, straight, closed, + .. } = self else { return None; @@ -108,6 +116,13 @@ impl EntityGeom { }; Some(GLine { p1, p2 }) } + + fn arc_segment(&self, index: usize) -> Option { + let EntityGeom::Polyline { arcs, .. } = self else { + return None; + }; + arcs.get(index).copied().flatten() + } } /// `Tangent`/`Normal` only ever care about "a whole circle-shaped curve" or @@ -131,8 +146,14 @@ fn as_circle_or_line(g: EntityGeom, reference: ParametricRef) -> CircleOrLine { EntityGeom::Spline { .. } => CircleOrLine::Other, EntityGeom::Polyline { .. } => reference .segment_index() - .and_then(|index| g.line_segment(index)) - .map(CircleOrLine::Line) + .and_then(|index| { + g.line_segment(index) + .map(CircleOrLine::Line) + .or_else(|| { + g.arc_segment(index) + .map(|segment| CircleOrLine::Circle(segment.arc.circle)) + }) + }) .unwrap_or(CircleOrLine::Other), } } @@ -182,42 +203,114 @@ fn register_entity( sys.add_param(table.insertion_point.x, false), sys.add_param(table.insertion_point.y, false), ))), - EntityType::LwPolyline(polyline) => Some(EntityGeom::Polyline { - points: Rc::new( - super::dimension_assoc::source_points(entity) - .into_iter() - .map(|point| { - GPoint::new(sys.add_param(point.x, false), sys.add_param(point.y, false)) + EntityType::LwPolyline(polyline) => { + let source = super::dimension_assoc::source_points(entity); + let points: Vec<_> = source + .iter() + .map(|point| { + GPoint::new(sys.add_param(point.x, false), sys.add_param(point.y, false)) + }) + .collect(); + let arcs = (0..polyline.vertices.len()) + .map(|index| { + let next = if index + 1 < source.len() { + index + 1 + } else if polyline.is_closed { + 0 + } else { + return None; + }; + let bulge = polyline.vertices[index].bulge; + let shape = cadkernel::geom2d::BulgeArc::from_bulge( + [source[index].x, source[index].y], + [source[next].x, source[next].y], + bulge, + )?; + Some(PolylineArc { + arc: GArc { + circle: GCircle { + center: GPoint::new( + sys.add_param(shape.center[0], false), + sys.add_param(shape.center[1], false), + ), + rad: sys.add_param(shape.radius, false), + }, + start: points[index], + end: points[next], + start_angle: sys.add_param(shape.start_angle, false), + end_angle: sys.add_param(shape.start_angle + shape.sweep, false), + }, + sweep: shape.sweep, }) - .collect(), - ), - straight: Rc::new( - polyline - .vertices - .iter() - .map(|vertex| vertex.bulge.abs() <= 1e-9) - .collect(), - ), - closed: polyline.is_closed, - }), - EntityType::Polyline2D(polyline) => Some(EntityGeom::Polyline { - points: Rc::new( - super::dimension_assoc::source_points(entity) - .into_iter() - .map(|point| { - GPoint::new(sys.add_param(point.x, false), sys.add_param(point.y, false)) + }) + .collect(); + Some(EntityGeom::Polyline { + points: Rc::new(points), + straight: Rc::new( + polyline + .vertices + .iter() + .map(|vertex| vertex.bulge.abs() <= 1e-9) + .collect(), + ), + arcs: Rc::new(arcs), + closed: polyline.is_closed, + }) + } + EntityType::Polyline2D(polyline) => { + let source = super::dimension_assoc::source_points(entity); + let points: Vec<_> = source + .iter() + .map(|point| { + GPoint::new(sys.add_param(point.x, false), sys.add_param(point.y, false)) + }) + .collect(); + let arcs = (0..polyline.vertices.len()) + .map(|index| { + let next = if index + 1 < source.len() { + index + 1 + } else if polyline.is_closed() { + 0 + } else { + return None; + }; + let bulge = polyline.vertices[index].bulge; + let shape = cadkernel::geom2d::BulgeArc::from_bulge( + [source[index].x, source[index].y], + [source[next].x, source[next].y], + bulge, + )?; + Some(PolylineArc { + arc: GArc { + circle: GCircle { + center: GPoint::new( + sys.add_param(shape.center[0], false), + sys.add_param(shape.center[1], false), + ), + rad: sys.add_param(shape.radius, false), + }, + start: points[index], + end: points[next], + start_angle: sys.add_param(shape.start_angle, false), + end_angle: sys.add_param(shape.start_angle + shape.sweep, false), + }, + sweep: shape.sweep, }) - .collect(), - ), - straight: Rc::new( - polyline - .vertices - .iter() - .map(|vertex| vertex.bulge.abs() <= 1e-9) - .collect(), - ), - closed: polyline.is_closed(), - }), + }) + .collect(); + Some(EntityGeom::Polyline { + points: Rc::new(points), + straight: Rc::new( + polyline + .vertices + .iter() + .map(|vertex| vertex.bulge.abs() <= 1e-9) + .collect(), + ), + arcs: Rc::new(arcs), + closed: polyline.is_closed(), + }) + } EntityType::Line(l) => { let p1 = GPoint::new( sys.add_param(l.start.x, false), @@ -439,6 +532,42 @@ fn resolve_constraint_point( ))); return Some(point); } + if let Some(segment) = reference + .segment_midpoint_index() + .and_then(|index| geometry.arc_segment(index)) + { + let (start, end) = { + let store = sys.store(); + ( + store.get(segment.arc.start_angle), + store.get(segment.arc.end_angle), + ) + }; + let parameter_value = (start + end) * 0.5; + let parameter = sys.add_param(parameter_value, false); + sys.add_constraint(Rc::new(CenterOfGravity::new( + parameter, + vec![segment.arc.start_angle, segment.arc.end_angle], + vec![0.5, 0.5], + ))); + let value = cadkernel_constraints::geo::Curve::value( + &segment.arc, + sys.store(), + parameter_value, + 0.0, + None, + ); + let point = GPoint::new(sys.add_param(value.x, false), sys.add_param(value.y, false)); + let curve = Rc::new(segment.arc); + sys.add_constraint(Rc::new(CurveValue::new( + point, + point.x, + curve.clone(), + parameter, + ))); + sys.add_constraint(Rc::new(CurveValue::new(point, point.y, curve, parameter))); + return Some(point); + } if marker != -2 { return None; } @@ -935,8 +1064,22 @@ fn build_constraint( } EntityGeom::Polyline { .. } => b .segment_index() - .and_then(|index| geom.line_segment(index)) - .map(|line| vec![Rc::new(PointOnLine::new(p, line)) as Rc]) + .and_then(|index| { + geom.line_segment(index) + .map(|line| { + vec![Rc::new(PointOnLine::new(p, line)) as Rc] + }) + .or_else(|| { + geom.arc_segment(index).map(|segment| { + let zero = sys.add_param(0.0, true); + vec![Rc::new(P2CDistance::new( + segment.arc.circle, + p, + zero, + )) as Rc] + }) + }) + }) .unwrap_or_default(), EntityGeom::Point(_) => Vec::new(), } @@ -1564,6 +1707,7 @@ fn solve_scope( document: &acadrust::CadDocument, drawing_params: &ParameterTable, set: &ParametricConstraintSet, + driven_refs: &[ParametricRef], ) -> Option { let params = if set.local_parameters.is_empty() { drawing_params @@ -1604,9 +1748,8 @@ fn solve_scope( // its endpoint. Not tracked in `owner`: these are solver-internal // bookkeeping, never redundant with anything a user-facing constraint // could name, so there's no `ConstraintId` for them to report against. - for geom in cache.values() { - let EntityGeom::Arc(arc) = geom else { continue }; - let curve = Rc::new(*arc); + let add_arc_rules = |sys: &mut System, arc: GArc, sweep: Option| { + let curve = Rc::new(arc); sys.add_constraint(Rc::new(CurveValue::new( arc.start, arc.start.x, @@ -1631,6 +1774,25 @@ fn solve_scope( curve, arc.end_angle, ))); + if let Some(sweep) = sweep { + let target = sys.add_param(sweep, true); + sys.add_constraint(Rc::new(Difference::new( + arc.start_angle, + arc.end_angle, + target, + ))); + } + }; + for geom in cache.values() { + match geom { + EntityGeom::Arc(arc) => add_arc_rules(&mut sys, *arc, None), + EntityGeom::Polyline { arcs, .. } => { + for segment in arcs.iter().flatten() { + add_arc_rules(&mut sys, segment.arc, Some(segment.sweep)); + } + } + _ => {} + } } // Ellipse rules (same motivation as arc rules, different mechanism): @@ -1667,6 +1829,66 @@ fn solve_scope( sys.add_constraint(Rc::new(Difference::new(el.center.y, el.focus1.y, dy))); } + // The first ordered Coincident selection is the anchor for this solve. + // Pin either its addressed point or every defining parameter of its whole + // curve temporarily; the relation remains persistent, while these pins do + // not enter the drawing's constraint graph. + for reference in driven_refs { + if !set + .constraints + .iter() + .any(|constraint| constraint.refs.iter().any(|item| item.entity == reference.entity)) + { + continue; + } + let params_to_pin = if reference.marker.is_some() && reference.segment_index().is_none() { + resolve_constraint_point(document, &mut sys, &mut cache, *reference) + .map(|point| vec![point.x, point.y]) + .unwrap_or_default() + } else { + match resolve_ref(document, &mut sys, &mut cache, *reference) { + Some(EntityGeom::Point(point)) => vec![point.x, point.y], + Some(EntityGeom::Line(line)) + | Some(EntityGeom::Ray(line)) + | Some(EntityGeom::XLine(line)) => { + vec![line.p1.x, line.p1.y, line.p2.x, line.p2.y] + } + Some(EntityGeom::Polyline { points, .. }) => points + .iter() + .flat_map(|point| [point.x, point.y]) + .collect(), + Some(EntityGeom::Circle(circle)) => { + vec![circle.center.x, circle.center.y, circle.rad] + } + Some(EntityGeom::Arc(arc)) => vec![ + arc.circle.center.x, + arc.circle.center.y, + arc.circle.rad, + arc.start_angle, + arc.end_angle, + ], + Some(EntityGeom::Ellipse(ellipse)) => vec![ + ellipse.center.x, + ellipse.center.y, + ellipse.focus1.x, + ellipse.focus1.y, + ellipse.radmin, + ], + Some(EntityGeom::Spline { curve, .. }) => curve + .poles + .iter() + .flat_map(|point| [point.x, point.y]) + .collect(), + None => Vec::new(), + } + }; + for parameter in params_to_pin { + let value = sys.store().get(parameter); + let target = sys.add_param(value, true); + sys.add_constraint(Rc::new(Equal::new(parameter, target, 1.0))); + } + } + let partitions = sys.partition(); for sub in &partitions { solve_dl(sub, sys.store_mut()); @@ -1683,7 +1905,9 @@ fn solve_scope( .map(|g| match g { EntityGeom::Point(_) => 2, EntityGeom::Line(_) | EntityGeom::Ray(_) | EntityGeom::XLine(_) => 4, - EntityGeom::Polyline { points, .. } => points.len() * 2, + EntityGeom::Polyline { points, arcs, .. } => { + points.len() * 2 + arcs.iter().flatten().count() * 5 + } EntityGeom::Circle(_) => 3, // Raw param count (center×2, rad, start×2, end×2, both // angles) — same "raw, not netted against its own @@ -2151,9 +2375,18 @@ impl Scene { /// the resulting `(Handle, ChangeKind::Modified)` entries the same way /// `refresh_associative_dimensions`/`_hatches` do, for `bump_entities` /// to fold into its own `changes` vec. + #[cfg(test)] pub(crate) fn refresh_parametric_constraints( &mut self, changes: &[(Handle, ChangeKind)], + ) -> Vec<(Handle, ChangeKind)> { + self.refresh_parametric_constraints_with_driven(changes, &[]) + } + + pub(crate) fn refresh_parametric_constraints_with_driven( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[ParametricRef], ) -> Vec<(Handle, ChangeKind)> { // An erased entity takes its constraints with it instead of leaving // dangling references. Done first, @@ -2200,6 +2433,7 @@ impl Scene { &self.document, &self.named_parameters, &self.parametric_constraints[i], + driven_refs, ) else { continue; }; @@ -2237,7 +2471,7 @@ impl Scene { continue; } let Some((solved, _dof, _conflicts)) = - solve_scope(&self.document, &self.named_parameters, set) + solve_scope(&self.document, &self.named_parameters, set, &[]) else { continue; }; diff --git a/src/scene/view/dispatch.rs b/src/scene/view/dispatch.rs index a28b2f98f..d58128961 100644 --- a/src/scene/view/dispatch.rs +++ b/src/scene/view/dispatch.rs @@ -80,6 +80,25 @@ pub fn properties_sectioned( } else { sections.extend(groups); } + if let Some(thickness) = entity_thickness(entity) { + let already_present = sections.iter().flat_map(|section| §ion.props).any(|property| { + property.field == "thickness" + || property.field == "sl_thickness" + || property.label == crate::t!("Thickness").as_ref() + }); + if !already_present { + if let Some(geometry) = sections + .iter_mut() + .find(|section| section.title == crate::t!("Geometry").as_ref()) + { + geometry.props.push(crate::entities::common::edit_prop( + crate::t!("Thickness").as_ref(), + "thickness", + thickness, + )); + } + } + } sections } From 1d9fddbdd85a5c5356804fa81d2061f26ae46dbb Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:14:41 +0300 Subject: [PATCH 25/33] Render tangent constraint glyph --- src/ui/overlay.rs | 47 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 40bde4606..eb9b38cfb 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -36,6 +36,22 @@ fn constraint_glyph_size(label: &str) -> Size { Size::new(w, h) } +fn draw_tangent_constraint_glyph( + frame: &mut canvas::Frame, + center: Point, + color: Color, +) { + let radius = 4.5; + let circle_center = Point::new(center.x - 0.3, center.y + 2.75); + let diagonal = radius * std::f32::consts::FRAC_1_SQRT_2; + let contact = Point::new(circle_center.x - diagonal, circle_center.y - diagonal); + let tangent_end = Point::new(contact.x + 8.0, contact.y - 8.0); + let stroke = canvas::Stroke::default().with_color(color).with_width(1.35); + + frame.stroke(&canvas::Path::circle(circle_center, radius), stroke.clone()); + frame.stroke(&canvas::Path::line(contact, tangent_end), stroke); +} + fn constraint_glyph_box( anchor: Point, outward: [f32; 2], @@ -1790,19 +1806,24 @@ impl canvas::Program for SelectionCanvas { canvas::Stroke::default().with_color(selected_ring).with_width(2.0), ); } - frame.fill_text(canvas::Text { - content: label.clone(), - position: Point::new( - top_left.x + size.width * 0.5, - top_left.y + size.height * 0.5, - ), - color: fg, - size: iced::Pixels(CONSTRAINT_GLYPH_SIZE), - align_x: iced::alignment::Horizontal::Center.into(), - align_y: iced::alignment::Vertical::Center, - shaping: iced::advanced::text::Shaping::Advanced, - ..Default::default() - }); + let glyph_center = Point::new( + top_left.x + size.width * 0.5, + top_left.y + size.height * 0.5, + ); + if label == "T" { + draw_tangent_constraint_glyph(frame, glyph_center, fg); + } else { + frame.fill_text(canvas::Text { + content: label.clone(), + position: glyph_center, + color: fg, + size: iced::Pixels(CONSTRAINT_GLYPH_SIZE), + align_x: iced::alignment::Horizontal::Center.into(), + align_y: iced::alignment::Vertical::Center, + shaping: iced::advanced::text::Shaping::Advanced, + ..Default::default() + }); + } } if let Some(index) = hovered { let red = Color::from_rgb(1.0, 0.0, 0.0); From d54fe5fb974e27346c0642c6eda78a1c86fd4466 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:44:42 +0300 Subject: [PATCH 26/33] Preserve live entity ownership during updates --- src/app/command_driver.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 1f59e9db5..abc196da5 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3597,11 +3597,15 @@ impl OpenCADStudio { // snapshot — the create already pushed one, so the whole object // reverts as a unit. if let Some(old) = self.tabs[i].scene.document.get_entity_mut(handle) { - let old_handle = old.as_entity().handle(); - let layer = old.as_entity().layer().to_string(); + // The initial live commit assigns the handle, owning block, + // current layer and common display properties. Geometry + // refreshes must retain all of that identity; replacing only + // the handle and layer reset owner_handle to NULL and made + // the completed entity unavailable to scoped operations such + // as parametric constraints. + let common = old.common().clone(); let mut new = entity; - new.as_entity_mut().set_handle(old_handle); - new.as_entity_mut().set_layer(layer); + *new.common_mut() = common; *old = new; self.tabs[i] .scene From 34b7d9481bb26b135a71c7e5e45f34c99a924824 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:04:14 +0300 Subject: [PATCH 27/33] Keep point-on-arc constraints within their sweep --- Cargo.lock | 4 +-- Cargo.toml | 4 +-- src/scene/parametric_solve.rs | 56 ++++++++++++++++++++++++----------- 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96ae7c765..75ece6af5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -919,7 +919,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=eb3cfe7#eb3cfe7b478230897bfb9094e7bc70aa4d00411b" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=0699490#069949099e08c51807681ee3f525bb525b42e006" dependencies = [ "acadrust", "cavalier_contours", @@ -930,7 +930,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=eb3cfe7#eb3cfe7b478230897bfb9094e7bc70aa4d00411b" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=0699490#069949099e08c51807681ee3f525bb525b42e006" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 8441fc21f..1752c0b4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,8 +33,8 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "eb3cfe7", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "eb3cfe7" } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0699490", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0699490" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index af5a32af8..5424028f2 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -13,7 +13,7 @@ use cadkernel_constraints::constraints::circle_arc::{C2LDistance, P2CDistance, T use cadkernel_constraints::constraints::conic::{ EqualMajorAxesConic, PointOnEllipse, TangentEllipseLine, }; -use cadkernel_constraints::constraints::curve_generic::CurveValue; +use cadkernel_constraints::constraints::curve_generic::{BoundedArcValue, CurveValue}; use cadkernel_constraints::constraints::point_line::{ CenterOfGravity, Difference, Equal, EqualLineLength, MidpointOnLine, P2PDistance, Parallel as ParallelConstraint, Perpendicular as PerpendicularConstraint, PointOnLine, @@ -364,7 +364,11 @@ fn register_entity( ); let rad = sys.add_param(a.radius, false); let start_angle = sys.add_param(a.start_angle, false); - let end_angle = sys.add_param(a.end_angle, false); + let end_angle = sys.add_param( + a.start_angle + + (a.end_angle - a.start_angle).rem_euclid(std::f64::consts::TAU), + false, + ); let start_seed = a.start_point(); let end_seed = a.end_point(); let start = GPoint::new( @@ -855,6 +859,35 @@ fn build_constraint( resolve_constraint_point(document, sys, cache, r) }; + let point_on_bounded_arc = |sys: &mut System, point: GPoint, arc: GArc| { + let normalized = { + let store = sys.store(); + let point_angle = + (store.get(point.y) - store.get(arc.circle.center.y)).atan2( + store.get(point.x) - store.get(arc.circle.center.x), + ); + let start = store.get(arc.start_angle); + let end = store.get(arc.end_angle); + let sweep = end - start; + if sweep.abs() <= 1e-12 { + 0.0 + } else { + let midpoint = (start + end) * 0.5; + let unwrapped = point_angle + + ((midpoint - point_angle) / std::f64::consts::TAU).round() + * std::f64::consts::TAU; + ((unwrapped - start) / sweep).clamp(0.0, 1.0) + } + }; + let parameter = sys.add_param(normalized.sqrt().asin(), false); + vec![ + Rc::new(BoundedArcValue::new(point, point.x, arc, parameter)) + as Rc, + Rc::new(BoundedArcValue::new(point, point.y, arc, parameter)) + as Rc, + ] + }; + // `Coincident`/`Concentric`/`CenterPoint` all solve identically — two // points (an endpoint, a circle's center via the existing `-3` marker, // or a plain point) held equal on both axes. Only the DWG-native class @@ -1025,14 +1058,7 @@ fn build_constraint( let zero = sys.add_param(0.0, true); vec![Rc::new(P2CDistance::new(circ, p, zero))] } - // Same "distance to the underlying circle is zero" relation - // as the `Circle` arm — doesn't restrict the point to - // within the arc's own sweep, matching that same - // pre-existing simplification for `Circle`. - EntityGeom::Arc(a) => { - let zero = sys.add_param(0.0, true); - vec![Rc::new(P2CDistance::new(a.circle, p, zero))] - } + EntityGeom::Arc(a) => point_on_bounded_arc(sys, p, a), EntityGeom::Ellipse(ellipse) => { vec![Rc::new(PointOnEllipse::new(p, ellipse))] } @@ -1070,14 +1096,8 @@ fn build_constraint( vec![Rc::new(PointOnLine::new(p, line)) as Rc] }) .or_else(|| { - geom.arc_segment(index).map(|segment| { - let zero = sys.add_param(0.0, true); - vec![Rc::new(P2CDistance::new( - segment.arc.circle, - p, - zero, - )) as Rc] - }) + geom.arc_segment(index) + .map(|segment| point_on_bounded_arc(sys, p, segment.arc)) }) }) .unwrap_or_default(), From 922a0d1d30c7d8adf14b3f2405e44cf67499041d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:16:06 +0300 Subject: [PATCH 28/33] Publish automation discovery descriptor immediately --- src/app/control/transport.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/control/transport.rs b/src/app/control/transport.rs index aa29f10ba..adce0eeb7 100644 --- a/src/app/control/transport.rs +++ b/src/app/control/transport.rs @@ -52,6 +52,7 @@ fn listen(sender: mpsc::Sender) -> std::io::Result<()> { } let mut file = options.open(&path)?; write!(file, "{descriptor}")?; + drop(file); let clients = Arc::new(AtomicUsize::new(0)); for stream in listener.incoming().flatten() { if clients.fetch_add(1, Ordering::SeqCst) >= 8 { From bd9705ecf7727a005833d030329b49e0bc2a8808 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:31:24 +0300 Subject: [PATCH 29/33] Persist bulged polyline constraint segments --- src/scene/dwg_native_constraints.rs | 88 ++++++++++++++++++----------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index df6440aa0..067bdf456 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -536,18 +536,12 @@ impl<'a> GroupBuilder<'a> { } let entity = self.document.get_entity(handle)?; let points = super::dimension_assoc::source_points(entity); - let closed = match entity { + let (closed, bulge) = match entity { EntityType::LwPolyline(polyline) => { - if polyline.vertices.get(index)?.bulge.abs() > 1e-9 { - return None; - } - polyline.is_closed + (polyline.is_closed, polyline.vertices.get(index)?.bulge) } EntityType::Polyline2D(polyline) => { - if polyline.vertices.get(index)?.bulge.abs() > 1e-9 { - return None; - } - polyline.is_closed() + (polyline.is_closed(), polyline.vertices.get(index)?.bulge) } _ => return None, }; @@ -560,19 +554,44 @@ impl<'a> GroupBuilder<'a> { return None; }; let node_id = self.alloc_node_id(); - self.push_node( - node_id, - "AcConstrainedBoundedLine", - AssocConstraintNodeData::BoundedLine { - geometry_dependency: Handle::NULL, - geometry_node_id: node_id, - point: start, - direction: (end - start).normalize(), - is_ray: false, - start_point: start, - end_point: end, - }, - ); + if bulge.abs() <= 1e-9 { + self.push_node( + node_id, + "AcConstrainedBoundedLine", + AssocConstraintNodeData::BoundedLine { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: start, + direction: (end - start).normalize(), + is_ray: false, + start_point: start, + end_point: end, + }, + ); + } else { + let arc = cadkernel::geom2d::BulgeArc::from_bulge( + [start.x, start.y], + [end.x, end.y], + bulge, + )?; + self.push_node( + node_id, + "AcConstrainedArc", + AssocConstraintNodeData::Arc { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + center: Vector3::new(arc.center[0], arc.center[1], start.z), + normal: Vector3::UNIT_Z, + direction: Vector3::UNIT_X, + radius: arc.radius, + start_parameter: arc.start_angle, + end_parameter: arc.start_angle + arc.sweep, + reserved: 0.0, + start_point: start, + end_point: end, + }, + ); + } self.entities .entry(handle) .or_default() @@ -1537,7 +1556,7 @@ fn dependency_entity( (Some(EntityType::Line(_)), AssocConstraintNodeData::BoundedLine { is_ray, .. }) => !is_ray, ( Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_)), - AssocConstraintNodeData::BoundedLine { .. }, + AssocConstraintNodeData::BoundedLine { .. } | AssocConstraintNodeData::Arc { .. }, ) => true, (Some(EntityType::Ray(_)), AssocConstraintNodeData::BoundedLine { is_ray, .. }) => *is_ray, (Some(EntityType::XLine(_)), AssocConstraintNodeData::Line { .. }) => true, @@ -1580,13 +1599,18 @@ fn polyline_segment_reference( data: &AssocConstraintNodeData, work_plane: &[Vector3; 3], ) -> Option { - let AssocConstraintNodeData::BoundedLine { - start_point, - end_point, - .. - } = data - else { - return None; + let (start_point, end_point) = match data { + AssocConstraintNodeData::BoundedLine { + start_point, + end_point, + .. + } + | AssocConstraintNodeData::Arc { + start_point, + end_point, + .. + } => (*start_point, *end_point), + _ => return None, }; let [origin, axis_x, axis_y] = *work_plane; let normal = Vector3::new( @@ -1595,8 +1619,8 @@ fn polyline_segment_reference( axis_x.x * axis_y.y - axis_x.y * axis_y.x, ); let to_world = |point: Vector3| origin + axis_x * point.x + axis_y * point.y + normal * point.z; - let start_point = to_world(*start_point); - let end_point = to_world(*end_point); + let start_point = to_world(start_point); + let end_point = to_world(end_point); let entity_value = document.get_entity(entity)?; let closed = match entity_value { EntityType::LwPolyline(polyline) => polyline.is_closed, From 612124b84b84e6817883774f5bf1bef9ba48c67f Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 15 Sep 2026 12:20:48 +0100 Subject: [PATCH 30/33] feat(ui): implement themed 5-section ribbon object color dropdown --- .cargo/config.toml | 1 + src/app/automation.rs | 11 ++ src/app/update/command.rs | 9 +- src/app/update/mod.rs | 33 ++-- src/app/view/mod.rs | 1 + src/ui/ribbon/color_dropdown.rs | 333 ++++++++++++++++++++++++++++++++ src/ui/ribbon/mod.rs | 32 ++- 7 files changed, 381 insertions(+), 39 deletions(-) create mode 100644 src/ui/ribbon/color_dropdown.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index de96c3e4a..876a223e7 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -22,6 +22,7 @@ rustflags = [ [target.x86_64-pc-windows-gnu] rustflags = [ "-Clink-arg=-Wl,--stack,16777216", + "-Clink-arg=-Wl,--exclude-all-symbols", ] [target.x86_64-pc-windows-msvc] diff --git a/src/app/automation.rs b/src/app/automation.rs index 3b53ce982..8012361d7 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -1669,4 +1669,15 @@ mod tests { Some("MTP") ); } + + #[test] + fn test_open_color_dropdown() { + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let wid = app.main_window.unwrap_or_else(iced::window::Id::unique); + let _ = app.view(wid); + let _ = app.update(Message::ToggleRibbonDropdown("PROP_COLOR".to_string())); + let _ = app.view(wid); + } } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index fd58e70d5..8a3c081c4 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1999,10 +1999,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } pub(super) fn on_ribbon_color_changed(&mut self, color: AcadColor) -> Task { - let i = self.active_tab; - self.ribbon.prop_color_palette_open = false; - self.ribbon.close_dropdown(); - let handles = self.property_target_handles(i); + let i = self.active_tab; + self.ribbon.prop_color_palette_open = false; + self.ribbon.close_dropdown(); + self.note_recent_color(color); + let handles = self.property_target_handles(i); if handles.is_empty() { if self.has_property_selection(i) { return Task::none(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 299d060ac..1f999afd0 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -9131,27 +9131,30 @@ impl OpenCADStudio { } Message::ColorWindowPick(color) => { - // Keep only real colours in the recent list. ByLayer / ByBlock / None - // are logical CAD states rather than reusable colours. - if matches!( - &color, - acadrust::types::Color::Index(_) | acadrust::types::Color::Rgb { .. } - ) { - // No duplicates: selecting an existing colour moves it to the front. - if let Some(pos) = self.recent_colors.iter().position(|c| c == &color) { - self.recent_colors.remove(pos); - } - - self.recent_colors.insert(0, color.clone()); - self.recent_colors.truncate(12); - } - + self.note_recent_color(color); self.on_color_window_pick(color) } Message::DsSetHandle { field, value } => self.on_ds_set_handle(field, value), } } + pub(crate) fn note_recent_color(&mut self, color: acadrust::types::Color) { + // Keep only real colours in the recent list. ByLayer / ByBlock / None + // are logical CAD states rather than reusable colours. + if matches!( + &color, + acadrust::types::Color::Index(_) | acadrust::types::Color::Rgb { .. } + ) { + // No duplicates: selecting an existing colour moves it to the front. + if let Some(pos) = self.recent_colors.iter().position(|c| c == &color) { + self.recent_colors.remove(pos); + } + + self.recent_colors.insert(0, color); + self.recent_colors.truncate(12); + } + } + /// Load a named scale into the scale-manager editor buffers (name + the /// paper / drawing units); blank ratios when the scale isn't found. fn load_scale_editor(&mut self, name: &str) { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 943119b3d..0e3c7fdae 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2218,6 +2218,7 @@ bg={bg_ms:.1}ms n={view_count}" &history_dropdown_labels(&self.tabs[self.active_tab].history.redo_stack), self.win_size, self.tabs[self.active_tab].is_start, + &self.recent_colors, ) .unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into()); diff --git a/src/ui/ribbon/color_dropdown.rs b/src/ui/ribbon/color_dropdown.rs new file mode 100644 index 000000000..8ec7393b7 --- /dev/null +++ b/src/ui/ribbon/color_dropdown.rs @@ -0,0 +1,333 @@ +//! Object Color Dropdown panel. +//! +//! Provides a 5-section flyout ribbon color control: +//! 1. ByLayer and ByBlock logical color entries +//! 2. 45-swatch Quick Pick Grid (5 rows × 9 columns) +//! 3. Standard Index Colors (ACI 1–9) +//! 4. Recent Colors row (up to 9 swatches) +//! 5. "Select Color..." action to open the full Select Color dialog + +use super::widgets::{muted_text_style, popup_panel_style, popup_row_style}; +use crate::app::Message; +use crate::t; +use crate::ui::properties::acad_color_display; +use acadrust::types::Color as AcadColor; +use iced::widget::{button, column, container, row, text, Space}; +use iced::{Background, Border, Element, Length, Padding, Theme}; + +pub const PANEL_W: f32 = 202.0; +const SWATCH_SIZE: f32 = 18.0; +const SWATCH_GAP: f32 = 2.0; + +/// Curated 45-swatch quick pick grid measured from the AutoCAD ribbon control. +pub const QUICK_PICK_GRID: [[(u8, u8, u8); 9]; 5] = [ + [ + (0x00, 0x00, 0x00), + (0xF8, 0xD7, 0x31), + (0xCD, 0x69, 0x28), + (0x99, 0x1B, 0x1E), + (0x64, 0x21, 0x65), + (0x29, 0x31, 0x89), + (0x22, 0x34, 0x6E), + (0x13, 0x67, 0x34), + (0x72, 0x74, 0x30), + ], + [ + (0x63, 0x64, 0x66), + (0xF1, 0xEB, 0x1F), + (0xF2, 0x67, 0x22), + (0xCD, 0x20, 0x27), + (0x93, 0x27, 0x8F), + (0x45, 0x54, 0xA5), + (0x10, 0x56, 0x89), + (0x13, 0x9B, 0x48), + (0x99, 0x9B, 0x37), + ], + [ + (0x93, 0x95, 0x98), + (0xF4, 0xEE, 0x51), + (0xF8, 0x99, 0x1E), + (0xED, 0x1F, 0x24), + (0xA9, 0x53, 0xA0), + (0x5E, 0x67, 0xAF), + (0x27, 0x76, 0xBB), + (0x58, 0xBA, 0x48), + (0xC8, 0xC9, 0x2D), + ], + [ + (0xC7, 0xC8, 0xCA), + (0xF7, 0xF2, 0x81), + (0xFE, 0xCC, 0x66), + (0xF2, 0x71, 0x72), + (0xD1, 0x9C, 0xC7), + (0x98, 0x98, 0xCC), + (0x7A, 0xAF, 0xDF), + (0xAF, 0xD9, 0xAA), + (0xE5, 0xE8, 0x78), + ], + [ + (0xFF, 0xFF, 0xFF), + (0xF8, 0xF6, 0xB0), + (0xFE, 0xEA, 0xB9), + (0xF7, 0xAB, 0xAE), + (0xDD, 0xB3, 0xD4), + (0xCE, 0xCC, 0xE6), + (0xAD, 0xDD, 0xF7), + (0xD7, 0xEB, 0xD2), + (0xED, 0xEE, 0x99), + ], +]; + +/// The standard ACI 1..=9 index colors pulled directly from the ACI color table. +#[allow(dead_code)] +pub fn aci_1_9() -> [(u8, u8, u8); 9] { + std::array::from_fn(|i| { + acadrust::types::aci_table::aci_to_rgb((i + 1) as u8).unwrap_or((128, 128, 128)) + }) +} + +fn swatch_btn<'a>(color: AcadColor, cur_color: AcadColor) -> Element<'a, Message> { + let (bg, _) = acad_color_display(color); + let selected = color == cur_color; + + button( + Space::new() + .width(Length::Fixed(SWATCH_SIZE)) + .height(Length::Fixed(SWATCH_SIZE)), + ) + .on_press(Message::RibbonColorChanged(color)) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); + button::Style { + background: Some(Background::Color(bg)), + border: Border { + color: if selected { + palette.primary.base.color + } else if hovered { + palette.background.base.text + } else { + palette.background.neutral.color + }, + width: if selected { + 2.0 + } else if hovered { + 1.5 + } else { + 1.0 + }, + radius: 1.0.into(), + }, + ..Default::default() + } + }) + .padding(0) + .into() +} + +fn placeholder_swatch<'a>() -> Element<'a, Message> { + container( + Space::new() + .width(Length::Fixed(SWATCH_SIZE)) + .height(Length::Fixed(SWATCH_SIZE)), + ) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color.scale_alpha(0.5))), + border: Border { + color: palette.background.neutral.color.scale_alpha(0.5), + width: 1.0, + radius: 1.0.into(), + }, + ..Default::default() + } + }) + .into() +} + +fn divider<'a>() -> Element<'a, Message> { + container( + Space::new() + .width(Length::Fill) + .height(Length::Fixed(1.0)), + ) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }) + .into() +} + +fn logical_row<'a>( + color: AcadColor, + cur_color: AcadColor, + label: &'static str, +) -> Element<'a, Message> { + let (bg, _) = acad_color_display(color); + let selected = color == cur_color; + + let swatch = container( + Space::new() + .width(Length::Fixed(SWATCH_SIZE)) + .height(Length::Fixed(SWATCH_SIZE)), + ) + .style(move |theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(bg)), + border: Border { + color: if selected { + palette.primary.base.color + } else { + palette.background.neutral.color + }, + width: if selected { 2.0 } else { 1.0 }, + radius: 1.0.into(), + }, + ..Default::default() + } + }); + + button( + row![ + swatch, + text(t!(label)).size(11), + ] + .spacing(8) + .align_y(iced::Center), + ) + .on_press(Message::RibbonColorChanged(color)) + .style(popup_row_style) + .padding([3, 4]) + .width(Length::Fill) + .into() +} + +fn more_colors_row<'a>(active_color: AcadColor) -> Element<'a, Message> { + button( + row![text(t!("Select Color...")).size(11)] + .align_y(iced::Center), + ) + .on_press(Message::OpenColorWindow( + crate::app::ColorPickTarget::Ribbon, + active_color, + )) + .style(popup_row_style) + .padding([4, 4]) + .width(Length::Fill) + .into() +} + +/// Builds the complete 5-section color dropdown panel element. +pub fn color_dropdown_panel<'a>( + active_color: AcadColor, + recent_colors: &[AcadColor], +) -> Element<'a, Message> { + // 1. ByLayer and ByBlock + let by_layer = logical_row(AcadColor::ByLayer, active_color, "ByLayer"); + let by_block = logical_row(AcadColor::ByBlock, active_color, "ByBlock"); + let section1 = column![by_layer, by_block].spacing(2); + + // 2. 45-swatch Quick Pick Grid (5 rows × 9 columns) + let mut grid_rows = column![].spacing(SWATCH_GAP); + for row_colors in QUICK_PICK_GRID { + let mut r = row![].spacing(SWATCH_GAP); + for (red, green, blue) in row_colors { + let color = AcadColor::Rgb { + r: red, + g: green, + b: blue, + }; + r = r.push(swatch_btn(color, active_color)); + } + grid_rows = grid_rows.push(r); + } + + // 3. Standard Index Colors (ACI 1..=9) + let index_header = text(t!("Index Color")).size(10).style(muted_text_style); + let mut standard_row = row![].spacing(SWATCH_GAP); + for idx in 1u8..=9 { + let color = AcadColor::Index(idx); + standard_row = standard_row.push(swatch_btn(color, active_color)); + } + let section3 = column![index_header, standard_row].spacing(4); + + // 4. Recent Colors row (up to 9 swatches; pad with placeholders if fewer) + let recent_header = text(t!("Recent Colors")).size(10).style(muted_text_style); + let mut recent_row = row![].spacing(SWATCH_GAP); + let mut count = 0; + for &color in recent_colors.iter().take(9) { + recent_row = recent_row.push(swatch_btn(color, active_color)); + count += 1; + } + while count < 9 { + recent_row = recent_row.push(placeholder_swatch()); + count += 1; + } + let section4 = column![recent_header, recent_row].spacing(4); + + // 5. Select Color... + let section5 = more_colors_row(active_color); + + let content = column![ + section1, + divider(), + grid_rows, + divider(), + section3, + divider(), + section4, + divider(), + section5, + ] + .spacing(6); + + container(content) + .padding(Padding { + top: 6.0, + bottom: 6.0, + left: 12.0, + right: 12.0, + }) + .width(Length::Fixed(PANEL_W)) + .style(popup_panel_style) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_quick_pick_grid_dimensions() { + assert_eq!(QUICK_PICK_GRID.len(), 5); + for row in &QUICK_PICK_GRID { + assert_eq!(row.len(), 9); + } + } + + #[test] + fn test_aci_1_9() { + let colors = aci_1_9(); + assert_eq!(colors.len(), 9); + assert_eq!(colors[0], (255, 0, 0)); // Red + assert_eq!(colors[1], (255, 255, 0)); // Yellow + assert_eq!(colors[2], (0, 255, 0)); // Green + assert_eq!(colors[3], (0, 255, 255)); // Cyan + assert_eq!(colors[4], (0, 0, 255)); // Blue + assert_eq!(colors[5], (255, 0, 255)); // Magenta + assert_eq!(colors[6], (255, 255, 255)); // White + assert_eq!(colors[7], (128, 128, 128)); // Dark Gray + assert_eq!(colors[8], (192, 192, 192)); // Light Gray + } + + #[test] + fn test_panel_geometry() { + assert_eq!(PANEL_W, 202.0); + let grid_width = 9.0 * SWATCH_SIZE + 8.0 * SWATCH_GAP; + assert_eq!(grid_width, 178.0); + let padding_h = (PANEL_W - grid_width) / 2.0; + assert_eq!(padding_h, 12.0); + } +} diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index a5eb2ffe3..7a71921f0 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -23,6 +23,7 @@ use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem}; mod widgets; mod draw_panel; mod modify_panel; +mod color_dropdown; use widgets::{StyleContext, *}; mod collapse; use collapse::{CollapsePanels, Panel}; @@ -739,6 +740,7 @@ impl Ribbon { redo_labels: &[String], win: (f32, f32), is_start: bool, + recent_colors: &[AcadColor], ) -> Option> { if is_start { return None; @@ -820,7 +822,7 @@ impl Ribbon { } if open_id == PROP_COLOR_ID { - return self.prop_color_overlay(win); + return self.prop_color_overlay(win, recent_colors); } if open_id == PROP_LINETYPE_ID { return self.prop_linetype_overlay(win); @@ -1169,27 +1171,17 @@ impl Ribbon { ))) } - fn prop_color_overlay(&self, win: (f32, f32)) -> Option> { - let picker = crate::ui::color_select::color_list( - crate::ui::color_select::ColorExtras { - by_layer: true, - by_block: true, - ..Default::default() - }, - Message::RibbonColorChanged, - Message::OpenColorWindow( - crate::app::ColorPickTarget::Ribbon, - self.active_color, - ), - ); + fn prop_color_overlay( + &self, + win: (f32, f32), + recent_colors: &[AcadColor], + ) -> Option> { + let panel = color_dropdown::color_dropdown_panel(self.active_color, recent_colors); - let panel = container(picker) - .style(popup_panel_style) - .width(Length::Fixed(200.0)); - - let (align_right, h_pad, top) = self.dd_anchor(PROP_COLOR_ID, 200.0, win.0); + let (align_right, h_pad, top) = + self.dd_anchor(PROP_COLOR_ID, color_dropdown::PANEL_W, win.0); Some(dropdown_backdrop(position_ribbon_dropdown( - panel.into(), + panel, align_right, h_pad, top, From 0e42e3bf28e067ce808fe236f3812779719bf846 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 15 Sep 2026 16:09:00 +0100 Subject: [PATCH 31/33] fix(ui): keep DSETTINGS tabs on a single tab strip Replace the wrapping button row with a real single-row tab strip (tab-shaped buttons + divider, matching style-manager editor shell). - Remove Row::wrap() so Selection Cycling can't spill to row 2; clip overflow - Active tab uses primary.strong, inactive weak/strong on hover, top-only radius - Tighten to size 11 / padding [4,8], spacing 2 - Widen Drafting Settings modal 700x480 -> 780x500 to fit 7 tabs --- src/app/view/modal.rs | 4 +-- src/ui/window/drafting_settings.rs | 56 +++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 2291d8654..8df1e0324 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -292,8 +292,8 @@ impl OpenCADStudio { let confirm = self.drafting_settings_close_confirm; sized_flow( ex, - 700, - 480, + 780, + 500, |flow| { if let Some(state) = state { crate::ui::window::drafting_settings::view_window( diff --git a/src/ui/window/drafting_settings.rs b/src/ui/window/drafting_settings.rs index 2ea71b51c..c0541d274 100644 --- a/src/ui/window/drafting_settings.rs +++ b/src/ui/window/drafting_settings.rs @@ -111,19 +111,44 @@ pub fn view_window<'a>( }; // ── Tab Bar ────────────────────────────────────────────────────────── + // Real tab strip: a single non-wrapping row of tab-shaped buttons with a + // divider underneath, matching the style-manager editor shell. The row + // never wraps — it clips on overflow so "Selection Cycling" can't spill + // onto a second row. let tab_button = |tab: DraftingSettingsTab, label: Cow<'a, str>| { let is_active = state.active_tab == tab; - button(text(label).size(11.5)) + button(text(label).size(11)) .on_press(Message::DraftingSettingsTabChanged(tab)) - .style(if is_active { - button::primary - } else { - button::secondary + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let pair = match (is_active, status) { + (true, _) => palette.primary.strong, + ( + false, + button::Status::Hovered | button::Status::Pressed, + ) => palette.background.strong, + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: iced::border::Radius { + top_left: 4.0, + top_right: 4.0, + bottom_right: 0.0, + bottom_left: 0.0, + }, + }, + ..Default::default() + } }) - .padding([5, 9]) + .padding([4, 8]) }; - let tab_bar = row![ + let tab_row = row![ tab_button(DraftingSettingsTab::SnapAndGrid, crate::t!("Snap and Grid")), tab_button(DraftingSettingsTab::PolarTracking, crate::t!("Polar Tracking")), tab_button(DraftingSettingsTab::ObjectSnap, crate::t!("Object Snap")), @@ -132,8 +157,21 @@ pub fn view_window<'a>( tab_button(DraftingSettingsTab::QuickProperties, crate::t!("Quick Properties")), tab_button(DraftingSettingsTab::SelectionCycling, crate::t!("Selection Cycling")), ] - .spacing(4) - .wrap(); + .spacing(2) + .width(Fill) + .clip(true); + + let tab_divider = container(Space::new().width(Fill).height(1)) + .width(Fill) + .height(1) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.neutral.color, + )), + ..Default::default() + }); + + let tab_bar = column![tab_row, tab_divider].spacing(0).width(Fill); // ── Tab 1: Snap and Grid ───────────────────────────────────────────── let snap_and_grid_view = { From 8b925a5d521b225a3291deefccccd69c1493130e Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 15 Sep 2026 20:38:44 +0300 Subject: [PATCH 32/33] Fix polyline constraint glyph placement --- src/scene/parametric_constraints.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 9c7fe40cb..082da8eb3 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -649,12 +649,21 @@ pub(crate) fn glyph_placement( (line.start.z + line.end.z) * 0.5, ) }; - let line_normal = |line: &acadrust::entities::Line| { - let direction = Vector3::new(-(line.end.y - line.start.y), line.end.x - line.start.x, 0.0); + let segment_normal = |start: Vector3, end: Vector3| { + let direction = Vector3::new(-(end.y - start.y), end.x - start.x, 0.0); (direction.length_squared() > 1e-24) .then_some(direction) .unwrap_or(Vector3::UNIT_Y) }; + let line_normal = |line: &acadrust::entities::Line| segment_normal(line.start, line.end); + if let Some(segment) = r.segment_index() { + let anchor = resolve_point( + entity, + POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - segment as i32, + )?; + let [start, end] = constraint_segment_endpoints(document, *r)?; + return Some((anchor, segment_normal(start, end))); + } match (entity, r.marker) { (acadrust::EntityType::Line(line), None) => Some((line_midpoint(line), line_normal(line))), (acadrust::EntityType::Circle(circle), None | Some(-3)) => { @@ -1534,6 +1543,14 @@ mod tests { document .add_entity(acadrust::EntityType::Circle(circle)) .unwrap(); + let mut polyline = acadrust::entities::LwPolyline::from_points(vec![ + acadrust::types::Vector2::new(0.0, 0.0), + acadrust::types::Vector2::new(10.0, 0.0), + ]); + polyline.common.handle = h(3); + document + .add_entity(acadrust::EntityType::LwPolyline(polyline)) + .unwrap(); let constraint = |reference| ParametricConstraint { id: 0, @@ -1559,6 +1576,10 @@ mod tests { glyph_placement(&document, &constraint(ParametricRef::center(h(2)))).unwrap(); assert_eq!(anchor, Vector3::new(5.0, 0.0, 0.0)); assert_eq!(direction, Vector3::new(5.0, 0.0, 0.0)); + let (anchor, direction) = + glyph_placement(&document, &constraint(ParametricRef::segment(h(3), 0))).unwrap(); + assert_eq!(anchor, Vector3::new(5.0, 0.0, 0.0)); + assert_eq!(direction, Vector3::new(0.0, 10.0, 0.0)); } #[test] From d3c12431af481223e503685444c7edf58723fff7 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 15 Sep 2026 19:10:49 +0100 Subject: [PATCH 33/33] fix(command-line): standardize history markers and bold errors --- src/ui/command_line.rs | 148 ++++++++++++++++++++++++++++++++++------- 1 file changed, 124 insertions(+), 24 deletions(-) diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index a578fa99d..60ad63211 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -84,6 +84,23 @@ fn strip_option_listing(s: &str) -> String { out.replace(" :", ":").trim().to_string() } +// Font swaps must preserve U+276F, U+24D8, and U+2715. +const COMMAND_PREFIX: &str = "❯ "; +const INFO_PREFIX: &str = "ⓘ "; +const ERROR_PREFIX: &str = "✕ "; + +fn format_info(msg: &str) -> String { + if msg.starts_with(INFO_PREFIX) { + msg.to_string() + } else { + format!("{INFO_PREFIX}{msg}") + } +} + +fn format_error(msg: &str) -> String { + format!("{ERROR_PREFIX}{}: {msg}", t!("Invalid").to_uppercase()) +} + const MAX_HISTORY: usize = 64; #[derive(Clone)] @@ -335,7 +352,10 @@ impl CommandLine { } pub fn push_command(&mut self, cmd: &str) { - self.push(EntryKind::Command, format!("{} {cmd}", t!("Command:"))); + self.push( + EntryKind::Command, + format!("{COMMAND_PREFIX}{} {cmd}", t!("Command:")), + ); } pub fn push_output(&mut self, msg: &str) { self.push(EntryKind::Output, msg.to_string()); @@ -343,7 +363,7 @@ impl CommandLine { pub fn push_error(&mut self, msg: &str) { self.error_revision = self.error_revision.wrapping_add(1); self.last_error = Some(msg.to_owned()); - self.push(EntryKind::Error, format!("*{}* {msg}", t!("Invalid"))); + self.push(EntryKind::Error, format_error(msg)); } /// Append an error unless it is already the latest history line. Repeated /// retry failures should refresh the concise message, not flood history @@ -352,7 +372,7 @@ impl CommandLine { pub fn push_error_once(&mut self, msg: &str) { self.error_revision = self.error_revision.wrapping_add(1); self.last_error = Some(msg.to_owned()); - let text = format!("*{}* {msg}", t!("Invalid")); + let text = format_error(msg); if let Some(last) = self .history .last_mut() @@ -372,12 +392,7 @@ impl CommandLine { self.push(EntryKind::Error, format!("*{}* {msg}", t!("Warning"))); } pub fn push_info(&mut self, msg: &str) { - let text = if msg.starts_with("i ") { - msg.to_string() - } else { - format!("i {msg}") - }; - self.push(EntryKind::Info, text); + self.push(EntryKind::Info, format_info(msg)); } fn push(&mut self, kind: EntryKind, text: String) { self.history.push(HistoryEntry { @@ -407,11 +422,7 @@ impl CommandLine { e.created_at = Instant::now(); } if let Some(p) = &prompt { - let formatted = if p.starts_with("i ") { - p.clone() - } else { - format!("i {p}") - }; + let formatted = format_info(p); // Reuse the prompt line dispatch/step-transition just pushed; // otherwise add it. if self.history.last().map(|e| &e.text) != Some(&formatted) { @@ -583,13 +594,18 @@ impl CommandLine { .iter() .fold(column![].spacing(0), |col, entry| { let kind = entry.kind.clone(); + let is_error = kind == EntryKind::Error; let entry_text = |value: String| { - text(value).size(11).style({ + let mut txt = text(value).size(11).style({ let kind = kind.clone(); move |theme: &Theme| iced::widget::text::Style { color: Some(history_color(theme, &kind)), } - }) + }); + if is_error { + txt = txt.font(iced::Font::DEFAULT.weight(iced::font::Weight::Bold)); + } + txt }; // The current step's prompt is the single pinned line. When the // step offers options, render them as clickable buttons inline @@ -1041,13 +1057,19 @@ fn history_highlight_format( ) -> iced::advanced::text::highlighter::Format { iced::advanced::text::highlighter::Format { color: Some(history_color(theme, kind)), - font: None, + font: match kind { + EntryKind::Error => Some(iced::Font::DEFAULT.weight(iced::font::Weight::Bold)), + _ => None, + }, } } #[cfg(test)] mod tests { - use super::{mcp_status, ranked_matches, CommandLine}; + use super::{ + format_error, history_highlight_format, mcp_status, ranked_matches, CommandLine, EntryKind, + ERROR_PREFIX, INFO_PREFIX, + }; use crate::t; use rustc_hash::FxHashMap; @@ -1121,13 +1143,81 @@ mod tests { assert_eq!(m.first().map(String::as_str), Some("LINE"), "got {m:?}"); } + #[test] + fn history_markers_and_copy_text_are_clean() { + let mut line = CommandLine::new(); + line.clear_history(); + let command = "LINE"; + let info = "Object selected."; + let error = "Unable to save: file is in use."; + let output = "12.5, 7.0\nraw output"; + + line.push_command(command); + line.push_info(info); + line.push_error(error); + line.push_output(output); + + let expected = vec![ + format!("❯ {} {command}", t!("Command:")), + format!("{INFO_PREFIX}{info}"), + format!("{ERROR_PREFIX}{}: {error}", t!("Invalid").to_uppercase()), + output.to_string(), + ]; + let actual = line + .history + .iter() + .map(|entry| entry.text.as_str()) + .collect::>(); + assert_eq!( + actual, + expected.iter().map(String::as_str).collect::>() + ); + assert_eq!( + line.history + .iter() + .map(|entry| entry.kind.clone()) + .collect::>(), + vec![ + EntryKind::Command, + EntryKind::Info, + EntryKind::Error, + EntryKind::Output, + ] + ); + + let plain = line.history_plain_text(); + assert!(!plain.contains('*')); + assert_eq!(plain, expected.join("\n")); + assert_eq!(line.history[3].text.as_bytes(), output.as_bytes()); + } + + #[test] + fn error_history_uses_bold_highlight_format() { + let theme = iced::Theme::Light; + let bold = iced::Font::DEFAULT.weight(iced::font::Weight::Bold); + assert_eq!( + history_highlight_format(&EntryKind::Error, &theme).font, + Some(bold) + ); + for kind in [EntryKind::Command, EntryKind::Info, EntryKind::Output] { + assert_eq!(history_highlight_format(&kind, &theme).font, None); + } + } + #[test] fn issue_498_repeated_save_error_is_not_duplicated() { let mut line = CommandLine::new(); let initial_len = line.history.len(); - line.push_error_once(t!("Unable to save: file is in use.").as_ref()); - line.push_error_once(t!("Unable to save: file is in use.").as_ref()); + let message = t!("Unable to save: file is in use."); + let expected_error = format_error(message.as_ref()); + line.push_error_once(message.as_ref()); + line.push_error_once(message.as_ref()); assert_eq!(line.history.len(), initial_len + 1); + assert_eq!( + line.history.last().map(|entry| entry.text.as_str()), + Some(expected_error.as_str()) + ); + assert!(!line.history_plain_text().contains('*')); } #[test] @@ -1193,8 +1283,14 @@ mod tests { fn info_entries_carry_prefix_marker() { let mut line = CommandLine::new(); line.push_info("Object selected."); - assert_eq!(line.history.last().map(|e| e.text.as_str()), Some("i Object selected.")); - assert_eq!(line.history.last().map(|e| &e.kind), Some(&super::EntryKind::Info)); + assert_eq!( + line.history.last().map(|entry| entry.text.as_str()), + Some("ⓘ Object selected.") + ); + assert_eq!( + line.history.last().map(|entry| &entry.kind), + Some(&EntryKind::Info) + ); } #[test] @@ -1204,9 +1300,13 @@ mod tests { line.push_info("Specify first point:"); assert_eq!(line.history.len(), initial_len + 1); line.set_step_prompt(Some("Specify first point:".to_string())); - assert_eq!(line.history.len(), initial_len + 1, "prompt must reuse push_info entry without duplicating"); + assert_eq!( + line.history.len(), + initial_len + 1, + "prompt must reuse push_info entry without duplicating" + ); let last = line.history.last().unwrap(); assert!(last.pinned); - assert_eq!(last.text, "i Specify first point:"); + assert_eq!(last.text, "ⓘ Specify first point:"); } }