diff --git a/Cargo.lock b/Cargo.lock index d34c62597..ef3cd2898 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,7 @@ dependencies = [ "bincode", "bytemuck", "cadkernel", + "cadkernel-constraints", "clap", "console_error_panic_hook", "cosmic-text 0.15.0", @@ -38,7 +39,6 @@ dependencies = [ "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", - "ocs_doc_api", "ocs_plugin_api", "open", "printpdf", @@ -323,6 +323,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -893,6 +902,14 @@ dependencies = [ "spade", ] +[[package]] +name = "cadkernel-constraints" +version = "0.1.0" +source = "git+https://github.com/ramox81/cadkernel.git?rev=0ccc61978286a6ad8467ac02de566ffd8ee100b3#0ccc61978286a6ad8467ac02de566ffd8ee100b3" +dependencies = [ + "nalgebra", +] + [[package]] name = "calloop" version = "0.13.0" @@ -3178,6 +3195,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -3358,6 +3385,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "nalgebra" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d43ddcacf343185dfd6de2ee786d9e8b1c2301622afab66b6c73baf9882abfd" +dependencies = [ + "approx", + "matrixmultiply", + "nalgebra-macros", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "nalgebra-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3891,20 +3945,6 @@ dependencies = [ "objc2-foundation 0.2.2", ] -[[package]] -name = "ocs_doc_api" -version = "0.1.0" -dependencies = [ - "acadrust", - "bincode", - "cadkernel", - "ocs_plugin_api", - "serde", - "serde_json", - "thiserror 1.0.69", - "toml 1.1.3+spec-1.1.0", -] - [[package]] name = "ocs_plugin_api" version = "0.2.1" @@ -4607,6 +4647,12 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.12.0" @@ -5015,6 +5061,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -5228,6 +5283,19 @@ dependencies = [ "libc", ] +[[package]] +name = "simba" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -6532,6 +6600,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "widestring" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 228c8a5ab..cca2c840a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,6 @@ members = [ "crates/ocs_web_worker", "crates/dwg-thumbnailer", "crates/dwg-thumbnailer-win", - "crates/ocs_doc_api", ] [target.'cfg(windows)'.build-dependencies] @@ -33,6 +32,7 @@ clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "2ecf0c57d387e293d11042aa37637322f2ab9c01", features = ["serde"] } cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "0ccc61978286a6ad8467ac02de566ffd8ee100b3", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/ramox81/cadkernel.git", rev = "0ccc61978286a6ad8467ac02de566ffd8ee100b3" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -76,7 +76,6 @@ iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0 [target.'cfg(not(target_arch = "wasm32"))'.dependencies] getrandom = "0.3" ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } -ocs_doc_api = { path = "crates/ocs_doc_api", features = ["host", "ipc"] } bincode = "1.3" meshopt = "0.6.2" i18n-embed = { version = "0.16", features = ["desktop-requester"] } diff --git a/assets/icons/constrain/angle.svg b/assets/icons/constrain/angle.svg new file mode 100644 index 000000000..e632dd2c8 --- /dev/null +++ b/assets/icons/constrain/angle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/constrain/center_point.svg b/assets/icons/constrain/center_point.svg new file mode 100644 index 000000000..cb4cb7333 --- /dev/null +++ b/assets/icons/constrain/center_point.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/coincident.svg b/assets/icons/constrain/coincident.svg new file mode 100644 index 000000000..263234192 --- /dev/null +++ b/assets/icons/constrain/coincident.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/colinear.svg b/assets/icons/constrain/colinear.svg new file mode 100644 index 000000000..be8d671c9 --- /dev/null +++ b/assets/icons/constrain/colinear.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/concentric.svg b/assets/icons/constrain/concentric.svg new file mode 100644 index 000000000..d68acc43b --- /dev/null +++ b/assets/icons/constrain/concentric.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/distance.svg b/assets/icons/constrain/distance.svg new file mode 100644 index 000000000..278ee92ee --- /dev/null +++ b/assets/icons/constrain/distance.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/constrain/equal.svg b/assets/icons/constrain/equal.svg new file mode 100644 index 000000000..bee8e3fec --- /dev/null +++ b/assets/icons/constrain/equal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/equal_distance.svg b/assets/icons/constrain/equal_distance.svg new file mode 100644 index 000000000..9b027b7fd --- /dev/null +++ b/assets/icons/constrain/equal_distance.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/constrain/fixed.svg b/assets/icons/constrain/fixed.svg new file mode 100644 index 000000000..b0e75becb --- /dev/null +++ b/assets/icons/constrain/fixed.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/constrain/horizontal.svg b/assets/icons/constrain/horizontal.svg new file mode 100644 index 000000000..2259c70bf --- /dev/null +++ b/assets/icons/constrain/horizontal.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/constrain/midpoint.svg b/assets/icons/constrain/midpoint.svg new file mode 100644 index 000000000..23ac2c0f1 --- /dev/null +++ b/assets/icons/constrain/midpoint.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/normal.svg b/assets/icons/constrain/normal.svg new file mode 100644 index 000000000..f4ddef0cd --- /dev/null +++ b/assets/icons/constrain/normal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/constrain/parallel.svg b/assets/icons/constrain/parallel.svg new file mode 100644 index 000000000..891fd41f1 --- /dev/null +++ b/assets/icons/constrain/parallel.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/perpendicular.svg b/assets/icons/constrain/perpendicular.svg new file mode 100644 index 000000000..520becabb --- /dev/null +++ b/assets/icons/constrain/perpendicular.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/point_on_curve.svg b/assets/icons/constrain/point_on_curve.svg new file mode 100644 index 000000000..745845a1e --- /dev/null +++ b/assets/icons/constrain/point_on_curve.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/constrain/symmetric.svg b/assets/icons/constrain/symmetric.svg new file mode 100644 index 000000000..6a73ba883 --- /dev/null +++ b/assets/icons/constrain/symmetric.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/constrain/tangent.svg b/assets/icons/constrain/tangent.svg new file mode 100644 index 000000000..7d0422129 --- /dev/null +++ b/assets/icons/constrain/tangent.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/constrain/vertical.svg b/assets/icons/constrain/vertical.svg new file mode 100644 index 000000000..81c68fe6e --- /dev/null +++ b/assets/icons/constrain/vertical.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/modify_align.svg b/assets/icons/modify_align.svg new file mode 100644 index 000000000..3b661c84f --- /dev/null +++ b/assets/icons/modify_align.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/modify_break.svg b/assets/icons/modify_break.svg new file mode 100644 index 000000000..83c41029a --- /dev/null +++ b/assets/icons/modify_break.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/modify_breakatpoint.svg b/assets/icons/modify_breakatpoint.svg new file mode 100644 index 000000000..588d967f8 --- /dev/null +++ b/assets/icons/modify_breakatpoint.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/modify_draworder.svg b/assets/icons/modify_draworder.svg new file mode 100644 index 000000000..a5ae19f04 --- /dev/null +++ b/assets/icons/modify_draworder.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/modify_hatchedit.svg b/assets/icons/modify_hatchedit.svg new file mode 100644 index 000000000..82ea41111 --- /dev/null +++ b/assets/icons/modify_hatchedit.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/modify_join.svg b/assets/icons/modify_join.svg new file mode 100644 index 000000000..687232f44 --- /dev/null +++ b/assets/icons/modify_join.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/modify_lengthen.svg b/assets/icons/modify_lengthen.svg new file mode 100644 index 000000000..834650c5d --- /dev/null +++ b/assets/icons/modify_lengthen.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/modify_ncopy.svg b/assets/icons/modify_ncopy.svg new file mode 100644 index 000000000..a7ce9f0ba --- /dev/null +++ b/assets/icons/modify_ncopy.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/modify_overkill.svg b/assets/icons/modify_overkill.svg new file mode 100644 index 000000000..3ddb7bfa8 --- /dev/null +++ b/assets/icons/modify_overkill.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/modify_pedit.svg b/assets/icons/modify_pedit.svg new file mode 100644 index 000000000..ef1459050 --- /dev/null +++ b/assets/icons/modify_pedit.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/modify_reverse.svg b/assets/icons/modify_reverse.svg new file mode 100644 index 000000000..04c802a8d --- /dev/null +++ b/assets/icons/modify_reverse.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/modify_setbylayer.svg b/assets/icons/modify_setbylayer.svg new file mode 100644 index 000000000..9ce5827de --- /dev/null +++ b/assets/icons/modify_setbylayer.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/modify_splinedit.svg b/assets/icons/modify_splinedit.svg new file mode 100644 index 000000000..977c90ddc --- /dev/null +++ b/assets/icons/modify_splinedit.svg @@ -0,0 +1 @@ + diff --git a/crates/ocs_doc_api/ARCHITECTURE.md b/crates/ocs_doc_api/ARCHITECTURE.md deleted file mode 100644 index 8603f8a10..000000000 --- a/crates/ocs_doc_api/ARCHITECTURE.md +++ /dev/null @@ -1,45 +0,0 @@ -# Document API architecture - -`DocApi → Document → collections → typed handles` builds DTO requests. -`Transport::apply` carries one operation or a read-only query batch to the -executor. The native adapter binds each request to its existing `HostSession`. - -For out-of-process / worker-thread plugins, `doc_api_for_host(host)` constructs a -`DocApi` backed by `OcsPluginApiIpc`, which sends `PluginRequest::DocApiRequest` -through the host's `PluginRequestSender` and deserializes -`PluginResponse::DocApiResponse`. This keeps plugin code free of manual envelope -serialization while reusing the same executor that the in-process transport uses. - -The executor validates inputs and dispatches through `DocApiBackend`. Backends -must prepare fallible geometry and serialization before changing the document. -Bulk writes prepare all entities, begin one undo record, apply the prepared -changes, then finalize once. Cancellation releases a pending undo capture; -it is not a rollback mechanism for partially applied writes. - -The native adapter reuses scene changes, document history, entity transforms -and profile conversion. Solid construction, sweeps, booleans, meshing and mass -properties belong to cadkernel. Profile planes preserve their source elevation -and orientation. Geometry revisions advance when the scene changes; a compound -operation may advance the revision more than once while recording one undo step. - -`Operation` and `Query` are manually maintained wire enums. Append variants; -do not reorder them. The consistency tests verify their bincode discriminants. -`spec/entities.toml` supplies family, constructor and method mappings. `build.rs` -generates the API reference and binding schema. The layout snapshot is curated -vocabulary, not a reflected binary codec schema. - -Layer CRUD is a document-table family, not an entity family. The API carries -layer properties in the plain-data `LayerInfo` DTO and the host translates them -to/from `acadrust::tables::Layer`. Layer names are case-insensitive. The host -rejects duplicate names, deletion of layer "0" or the current layer, and removal -of a layer that still has entities assigned. - -XDATA and XRECORD are serialization roundtrip families: the API carries their -payloads as plain-data DTOs and the host stores/retrieves them without semantic -interpretation. XDATA records are keyed by registered application name on any -entity; XRECORD objects are standalone named dictionaries of group-code/value -entries. Both preserve exact bytes/values across save and reload. - -To extend the API, update the DTO, backend, executor, facade and spec together, -add a native behavior test, and rebuild the generated reference/schema. New -backend methods must reject unsupported behavior explicitly. diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml deleted file mode 100644 index dc0c9a7b1..000000000 --- a/crates/ocs_doc_api/Cargo.toml +++ /dev/null @@ -1,66 +0,0 @@ -[package] -name = "ocs_doc_api" -version = "0.1.0" -edition = "2021" -description = "Object-oriented, transport-agnostic CAD entity API for OpenCADStudio plugins and hosts (v2)." -license = "MIT" -build = "build.rs" - -# Dependency wiring: pin the SAME cadkernel/acadrust git revs -# as the host so `Body`/`EntityType` types are ABI-identical. -[dependencies] -serde = { version = "1", features = ["derive"] } -bincode = "1.3" -thiserror = "1" - -# Host adapters convert ObjectId to acadrust handles; kernel handles stay local. -acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "2ecf0c57d387e293d11042aa37637322f2ab9c01", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "0ccc61978286a6ad8467ac02de566ffd8ee100b3", features = ["acis", "offset"], optional = true } -ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } - -[build-dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" -toml = "1" - -[dev-dependencies] -serde_json = "1" -toml = "1" - -[features] -# Default keeps the facade and DTOs independent of geometry dependencies. -default = [] -# host: pull acadrust + cadkernel + the executor/backend trait (in-process side). -host = ["acadrust", "cadkernel"] -# ipc: pull ocs_plugin_api for the IPC transport adapter (plugin side). -ipc = ["acadrust", "ocs_plugin_api"] -# doc_api_host: convenience helper that builds a DocApi from a HostApi (IPC-only). -doc_api_host = ["ipc"] - -[lib] -name = "ocs_doc_api" -path = "src/lib.rs" - -[[test]] -name = "mock_backend" -required-features = ["host"] - -[[example]] -name = "e1_primitives_and_queries" -required-features = ["host"] - -[[example]] -name = "e2_intersect_two_solids" -required-features = ["host"] - -[[example]] -name = "e3_transform_and_chain" -required-features = ["host"] - -[[example]] -name = "e4_2d_entities_and_bulk" -required-features = ["host"] - -[[example]] -name = "e5_failure_and_guards" -required-features = ["host"] diff --git a/crates/ocs_doc_api/README.md b/crates/ocs_doc_api/README.md deleted file mode 100644 index cc38a56ca..000000000 --- a/crates/ocs_doc_api/README.md +++ /dev/null @@ -1,139 +0,0 @@ -# ocs_doc_api - -Typed document operations and queries for OpenCADStudio plugins and hosts. -The same facade runs over an in-process backend or the plugin IPC channel. - -```rust -use ocs_doc_api::{ApiResult, DocApi}; - -fn intersect_solids(api: &DocApi) -> ApiResult { - let doc = api.document(api.active_tab()); - let a = doc.solids().create_cuboid([0.0; 3], [10.0; 3])?; - let b = doc.solids().create_cuboid([5.0; 3], [10.0; 3])?; - a.intersect(&b)?.volume() -} -``` - -Each successful write marks the document changed and records one undo step. -A failed operation leaves the document unchanged. Bulk creation and transforms -prepare every entity before applying changes. Separate calls commit separately; -`OpGroup` offers best-effort cleanup of creations, not rollback. - -Boolean `intersect`, `union` and `subtract` replace the first solid and erase the -second. `intersects` only tests bounding-box overlap. Volume and centroid are -computed by the kernel from a tessellated solid (chord tolerance 0.1 drawing units, -angular tolerance 0.0001 radians), then cached per geometry revision. A request -may compute at most 32 uncached mass properties; excess work returns an error. -Bulk operations and query batches are capped at 100,000 items. - -Transports bind to one document tab. Typed handles from another transport and -requests for another tab are rejected. Raw `ObjectId` values are relative to the -receiving document. Errors are serialized as `ApiError` variants over IPC. - -Layer CRUD is exposed through `Document::layers()`, `create_layer(info)`, -`update_layer(name, info)`, `delete_layer(name)` and through the `layer()` / -`set_layer(...)` methods on every entity handle (`Line`, `Circle`, `Solid`, -`Entity`, etc.). Layer properties are carried as the plain-data `LayerInfo` DTO -with `LayerFlags`, `Color` and `LineWeight`. The host enforces CAD-style rules: -layer "0" and the current layer cannot be deleted, duplicate names are rejected, -and a layer still referenced by entities cannot be removed. - -XDATA and XRECORD payloads are carried as plain-data, serde-compatible DTOs -(`XDataRecord`, `XRecordSpec`, and their value/entry types) and roundtripped -verbatim by the host. Every `Entity` handle can read/write one XDATA record per -registered application name via `xdata(application_name)` and `set_xdata(...)`, -and standalone XRECORD objects can be created with `doc.entities().create_xrecord(...)` -and updated through the `XRecord` handle. - -| Feature | Use | -|---|---| -| Default | DTOs, facade, transport trait and binding schema | -| `host` | Kernel and entity adapters, executor, `DocApiBackend`, `InProcess` | -| `ipc` | `OcsPluginApiIpc` adapter for a plugin connected to the host | -| `doc_api_host` | Convenience `doc_api_for_host(&dyn HostApi) -> Option` for out-of-process / worker-thread plugins | - -### Connecting from a plugin - -For out-of-process or worker-thread plugins that receive `&dyn HostApi` (or any -`ocs_plugin_api::host::HostApi` implementor), enable the `doc_api_host` feature -and use the helper instead of manually serializing `DocApiEnvelope`s: - -```rust -use ocs_doc_api::doc_api_for_host; -use ocs_plugin_api::host::HostApi; - -fn on_dispatch(host: &mut dyn HostApi) -> Option<()> { - let doc_api = doc_api_for_host(host)?; - let doc = doc_api.document(host.tab_id()); - let cube = doc.solids().create_cuboid([0.0; 3], [10.0; 3]).ok()?; - Some(()) -} -``` - -`doc_api_for_host` returns `None` for hosts that do not expose a -`PluginRequestSender` (for example the in-process `HostSession`). In-process code -should use `DocApi::in_process(backend, tab_id)` with a concrete -`DocApiBackend`. - -### Plugin example: create, read, update and delete a line - -```rust -use ocs_doc_api::{doc_api_for_host, ApiResult, Color, LayerInfo, LineWeight}; -use ocs_plugin_api::host::HostApi; - -fn draw_and_modify_line(host: &mut dyn HostApi) -> ApiResult<()> { - // Build a DocApi from the host's plugin request channel. - let api = doc_api_for_host(host).ok_or_else(|| { - ocs_doc_api::ApiError::Transport("host does not expose a DocApi channel".into()) - })?; - let doc = api.document(api.active_tab()); - - // Ensure a target layer exists. - let mut info = LayerInfo::new("MyLayer"); - info.color = Color::Index(1); - info.line_weight = LineWeight::Value(25); - let _ = doc.create_layer(&info); // ok if it already exists - - // Create a line on that layer. - let line = doc.curves().create_line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])?; - line.set_layer("MyLayer")?; - - // Read it back. - assert_eq!(line.layer()?, "MYLAYER"); - - // Move the endpoint in place (same ObjectId). - line.transform(ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]))?; - - // Delete it; the document is left unchanged if this failed. - line.delete()?; - Ok(()) -} -``` - -### Plugin example: create a box and read its volume - -```rust -use ocs_doc_api::{doc_api_for_host, ApiResult}; -use ocs_plugin_api::host::HostApi; - -fn create_box_and_measure(host: &mut dyn HostApi) -> ApiResult { - let api = doc_api_for_host(host).ok_or_else(|| { - ocs_doc_api::ApiError::Transport("host does not expose a DocApi channel".into()) - })?; - let doc = api.document(api.active_tab()); - - let box_ = doc.solids().create_cuboid([0.0; 3], [10.0; 3])?; - let volume = box_.volume()?; - - // Boolean example: intersect with a second box. - let other = doc.solids().create_cuboid([5.0; 3], [10.0; 3])?; - let intersection = box_.intersect(&other)?; - let _intersection_volume = intersection.volume()?; - - Ok(volume) -} -``` - -See the [API reference](src/gen/api_reference.md) for constructors and methods, -[architecture](ARCHITECTURE.md) for backend rules, and -[binding guide](bindings/README.md) for the Python facade and transport contract. diff --git a/crates/ocs_doc_api/bindings/README.md b/crates/ocs_doc_api/bindings/README.md deleted file mode 100644 index deb13c55d..000000000 --- a/crates/ocs_doc_api/bindings/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Language bindings - -`src/gen/binding_schema.json` describes handle families, constructors, methods, -arguments and operation/query mappings. `build.rs` derives it from -`spec/entities.toml`. Its `layouts` section is manually maintained vocabulary; -it does not contain everything needed to generate a bincode codec. - -Generate and test the Python facade from the repository root: - -```sh -python3 crates/ocs_doc_api/bindings/python/generate.py > /tmp/ocs_doc.py -python3 -m unittest discover -s crates/ocs_doc_api/bindings/python -``` - -The generated `DocApi(transport, active_tab)` expects a caller-supplied -`transport.apply(envelope) -> Receipt`. It does not open a plugin connection. -An envelope has `version` and a Python tuple `("Op", operation_dict)` or -`("Queries", [query_dict])`. Enum payloads use externally tagged dictionaries, -for example `{"CreateSolid": {"Sphere": {"centre": [0, 0, 0], "radius": 2}}}`. -The bridge converts these objects to the Rust DTOs and bincode 1.3 wire format, -sends `DocApiRequest { tab_id, bytes }`, and decodes `Result`. -Host plugin API version 6 and document envelope version 1 are required. - -For Rust plugins compiled against `ocs_doc_api` with the `doc_api_host` feature, -use the `doc_api_for_host` helper to build a typed `DocApi` from any -`ocs_plugin_api::host::HostApi` that exposes a `PluginRequestSender` -(out-of-process / worker-thread hosts): - -```rust -use ocs_doc_api::doc_api_for_host; -use ocs_plugin_api::host::HostApi; - -fn on_dispatch(host: &mut dyn HostApi) -> Option<()> { - let doc_api = doc_api_for_host(host)?; - let doc = doc_api.document(host.tab_id()); - let cube = doc.solids().create_cuboid([0.0; 3], [10.0; 3]).ok()?; - // ... - Some(()) -} -``` - -In-process plugins should use `DocApi::in_process(backend, tab_id)` with a -concrete `DocApiBackend`; `doc_api_for_host` returns `None` for hosts that do not -expose a `PluginRequestSender`. - -Decode receipt outcomes as `{"NewId": 42}` or `{"NewIds": [42, 43]}` and query -results as externally tagged dictionaries such as `{"Volume": 12.5}`. The facade -unwraps these into typed handles or values. Layer results use `{"Layers": [...]}` -and `{"EntityLayer": "WALLS"}`. Layer properties (`LayerInfo`, `LayerFlags`, -`Color`, `LineWeight`) appear as plain dictionaries in operation payloads, e.g. -`{"CreateLayer": {"name": "Walls", "flags": {"frozen": false, "locked": false, -"frozen_in_new_viewport": false, "off": false}, "color": {"Index": 7}, -"line_type": "Continuous", "line_weight": {"Default": null}, -"plot_style": "", "is_plottable": true}}`. -XDATA and XRECORD payloads appear as nested externally tagged dictionaries -(e.g. `{"XData": {"application_name": ..., "values": [...]}}` or -`{"XRecord": {"name": ..., "entries": [...]}}`). The bridge must raise an -exception for an error response. Transport binding determines the tab; requesting -another tab or combining typed handles from different documents raises an error. diff --git a/crates/ocs_doc_api/bindings/python/generate.py b/crates/ocs_doc_api/bindings/python/generate.py deleted file mode 100644 index d361493ba..000000000 --- a/crates/ocs_doc_api/bindings/python/generate.py +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 -"""Generate a Python facade over a caller-supplied document transport. - -Usage: python generate.py ../../src/gen/binding_schema.json > ocs_doc.py -The transport converts these Python DTOs to/from the Rust wire representation. -""" -import json -import sys -from pathlib import Path - -HEADER = '''"""Generated document facade. See bindings/README.md for the transport contract.""" -from dataclasses import dataclass -from typing import Any - -@dataclass -class DocApiEnvelope: - version: int - body: Any - -@dataclass -class Receipt: - outcome: Any - query_results: list - new_revision: int - -@dataclass -class Entity: - id: int - _doc: "Document" - - def bounds(self): - return self._doc._apply_query({"GetBounds": {"id": self.id}}) - - def transform(self, placement): - self._doc._apply_op({"Transform": {"id": self.id, "placement": placement}}) - - def delete(self): - self._doc._apply_op({"Delete": {"id": self.id}}) - - def layer(self): - return self._doc._apply_query({"GetEntityLayer": {"id": self.id}}) - - def set_layer(self, layer): - self._doc._apply_op({"SetEntityLayer": {"id": self.id, "layer": layer}}) -''' - -DOCUMENT = ''' -@dataclass -class Document: - _transport: Any - _tab: int - - def _id(self, value): - if isinstance(value, Entity): - if value._doc._transport is not self._transport or value._doc._tab != self._tab: - raise ValueError("handle belongs to another document") - return value.id - return int(value) - - def _apply_op(self, op, want=None): - receipt = self._transport.apply(DocApiEnvelope(ENVELOPE_VERSION, ("Op", op))) - if want is None: - return None - if want.startswith("Vec<"): - handle = globals()[want[4:-1]] - return [handle(i, self) for i in receipt.outcome["NewIds"]] - return globals()[want](receipt.outcome["NewId"], self) - - def _apply_query(self, query): - receipt = self._transport.apply(DocApiEnvelope(ENVELOPE_VERSION, ("Queries", [query]))) - if len(receipt.query_results) != 1: - raise ValueError("expected one query result") - result = receipt.query_results[0] - if not isinstance(result, dict) or len(result) != 1: - raise ValueError("invalid query result") - return next(iter(result.values())) - - def revision(self): - return self._apply_query("GetGeometryRevision") - - def block_entities(self, block_name): - return self._apply_query({"GetBlockEntities": {"block_name": block_name}}) - - def solids(self): return Solids(self) - def curves(self): return Curves(self) - def entities(self): return Entities(self) - - def layers(self): - return self._apply_query({"ListLayers": {}}) - - def create_layer(self, info): - self._apply_op({"CreateLayer": info}) - - def update_layer(self, name, info): - self._apply_op({"UpdateLayer": {"name": name, "info": info}}) - - def delete_layer(self, name): - self._apply_op({"DeleteLayer": {"name": name}}) - -@dataclass -class DocApi: - _transport: Any - active_tab: int = 0 - - def document(self, tab): - if tab != self.active_tab: - raise ValueError("transport is bound to another tab") - return Document(self._transport, tab) -''' - - -def render_method(method, constructor=False): - args = method.get("args", []) - signature = "".join(", " + a["name"] for a in args) - variant = method.get("op") or method["query"] - fields = {} - for arg in args: - name, ty = arg["name"], arg["ty"] - if ty in ("EntityRef", "Solid", "ObjectId"): - fields[name] = f"self._doc._id({name})" - elif ty == "&[EntityRef]": - fields[name] = f"[self._doc._id(p) for p in {name}]" - else: - fields[name] = name - fixed = dict(method.get("fixed") or {}) - if variant in ("SolidBoolean", "GetIntersects"): - fields = {"a": "self.id", "b": fields.pop("other"), **fields} - fields.pop("other", None) - elif not constructor: - fields = {"id": "self.id", **fields} - if variant == "Revolve": - fields["axis"] = "(pivot, axis)" - fields.pop("pivot") - nested = fixed.pop("primitive", fixed.pop("curve", None)) - fields.update({key: repr(value) for key, value in fixed.items()}) - payload = "{" + ", ".join(repr(k) + ": " + v for k, v in fields.items()) + "}" - if variant == "CreateMany": - payload = "[{'Curve': {'Point': {'position': p}}} for p in positions]" - elif nested: - payload = "{" + repr(nested) + ": " + payload + "}" - message = "{" + repr(variant) + ": " + payload + "}" - if method.get("kind") == "query": - call = f"self._doc._apply_query({message})" - else: - want = method.get("returns") - if want == "()": - want = None - call = f"self._doc._apply_op({message}, {want!r})" - return f" def {method['name']}(self{signature}):\n return {call}\n" - - -def render(schema): - out = [HEADER, f"ENVELOPE_VERSION = {schema['envelope_version']}\n"] - handles = {name: {} for name in schema["object_model"]["handles"]} - collections = {"solids": {}, "curves": {}, "entities": {}} - for family in schema["families"]: - handle = family.get("handle") or family["name"] - for method in family.get("methods", []): - existing = handles[handle].get(method["name"]) - if existing is not None and existing != method: - raise ValueError(f"conflicting method: {handle}.{method['name']}") - handles[handle][method["name"]] = method - for constructor in family.get("constructors", []): - collections[family["collection"]][constructor["name"]] = constructor - # Families sharing Entity contribute to one class, preserving all methods. - entity_methods = "\n".join(render_method(m) for m in handles.pop("Entity").values()) - out[0] += entity_methods - for handle, methods in handles.items(): - out.append(f"\nclass {handle}(Entity):\n" + ( - "\n".join(render_method(m) for m in methods.values()) or " pass\n")) - for collection, constructors in collections.items(): - out.append(f"\nclass {collection.title()}:\n def __init__(self, doc):\n self._doc = doc\n") - out.extend(render_method(c, constructor=True) for c in constructors.values()) - out.append(DOCUMENT) - return "\n".join(out) - - -def main(): - default = Path(__file__).resolve().parents[2] / "src/gen/binding_schema.json" - path = Path(sys.argv[1]) if len(sys.argv) > 1 else default - sys.stdout.write(render(json.loads(path.read_text(encoding="utf-8")))) - - -if __name__ == "__main__": - main() diff --git a/crates/ocs_doc_api/bindings/python/test_generate.py b/crates/ocs_doc_api/bindings/python/test_generate.py deleted file mode 100644 index b63ed1fe8..000000000 --- a/crates/ocs_doc_api/bindings/python/test_generate.py +++ /dev/null @@ -1,75 +0,0 @@ -import inspect -import json -from pathlib import Path -import types -import sys -import unittest - -from generate import render - - -class BindingTest(unittest.TestCase): - def setUp(self): - self.schema = json.loads((Path(__file__).resolve().parents[2] / 'src/gen/binding_schema.json').read_text()) - self.module = types.ModuleType('generated') - sys.modules['generated'] = self.module - self.addCleanup(sys.modules.pop, 'generated') - exec(render(self.schema), self.module.__dict__) - module = self.module - - class Transport: - def __init__(self): - self.calls = [] - - def apply(self, envelope): - self.calls.append(envelope) - kind, payload = envelope.body - if kind == 'Queries': - return module.Receipt(None, [{'Volume': 125.0}], 2) - if 'CreateMany' in payload: - return module.Receipt({'NewIds': [10, 11]}, [], 2) - return module.Receipt({'NewId': 9}, [], 2) - - self.transport = Transport() - self.api = module.DocApi(self.transport, active_tab=4) - self.doc = self.api.document(4) - - def test_constructors_returns_and_wire_payloads(self): - solid = self.doc.solids().create_cuboid([0, 0, 0], [5, 5, 5]) - self.assertIsInstance(solid, self.module.Solid) - self.assertEqual(self.transport.calls[-1].body, ('Op', {'CreateSolid': {'Cuboid': {'origin': [0, 0, 0], 'size': [5, 5, 5]}}})) - self.assertEqual(solid.volume(), 125.0) - self.assertIsInstance(solid.intersect(solid), self.module.Solid) - self.assertEqual(self.transport.calls[-1].body[1], {'SolidBoolean': {'a': 9, 'b': 9, 'op': 'Intersection', 'erase_sources': True}}) - points = self.doc.curves().create_points([[0, 0, 0], [1, 1, 1]]) - self.assertEqual([p.id for p in points], [10, 11]) - self.assertTrue(all(isinstance(p, self.module.Point) for p in points)) - self.doc.solids().revolve(points[0], [0, 0, 0], [0, 0, 1], 3.14) - self.assertEqual(self.transport.calls[-1].body[1]['Revolve']['axis'], ([0, 0, 0], [0, 0, 1])) - self.doc.solids().loft(points) - self.assertEqual(self.transport.calls[-1].body[1], {'Loft': {'profiles': [10, 11]}}) - self.assertIsNone(points[0].delete()) - - def test_every_schema_method_and_constructor_survives_generation(self): - for family in self.schema['families']: - handle = getattr(self.module, family['handle']) - collection = getattr(self.doc, family['collection'])() - for method in family['methods']: - self.assertTrue(callable(getattr(handle, method['name']))) - for constructor in family['constructors']: - method = getattr(collection, constructor['name']) - self.assertEqual(list(inspect.signature(method).parameters), [a['name'] for a in constructor['args']]) - - def test_foreign_handles_and_tab_mismatch_do_not_send(self): - with self.assertRaises(ValueError): - self.api.document(5) - foreign = self.module.Solid(9, self.module.DocApi(object(), 4).document(4)) - local = self.module.Solid(9, self.doc) - for operation in [lambda: local.union(foreign), lambda: self.doc.solids().extrude(foreign, [0, 0, 1]), lambda: self.doc.solids().loft([foreign])]: - with self.assertRaises(ValueError): - operation() - self.assertEqual(self.transport.calls, []) - - -if __name__ == '__main__': - unittest.main() diff --git a/crates/ocs_doc_api/build.rs b/crates/ocs_doc_api/build.rs deleted file mode 100644 index c951f0d66..000000000 --- a/crates/ocs_doc_api/build.rs +++ /dev/null @@ -1,452 +0,0 @@ -//! Regenerate the API reference and binding schema from the curated spec. - -use std::env; -use std::fs; -use std::path::PathBuf; - -use serde::Deserialize; - -#[derive(Deserialize)] -struct Spec { - meta: Meta, - #[serde(default)] - family: Vec, - #[serde(default)] - kernel_map: Vec, -} - -#[derive(Deserialize)] -struct Meta { - envelope_version: u16, -} - -#[derive(Deserialize)] -struct Family { - name: String, - #[serde(default)] - acadrust_variant: Option, - #[serde(default)] - handle: Option, - #[serde(default)] - collection: Option, - #[serde(default)] - description: Option, - #[serde(default)] - constructor: Vec, - #[serde(default)] - method: Vec, -} - -#[derive(Deserialize)] -// `op`/`fixed` are part of the spec schema (used by binding_schema consumers and -// future codegen) even though api_reference generation doesn't read them yet. -#[allow(dead_code)] -struct Constructor { - name: String, - #[serde(default)] - args: Vec, - #[serde(default)] - op: Option, - #[serde(default)] - fixed: Option, - #[serde(default)] - bulk: bool, - #[serde(default)] - returns: Option, - #[serde(default)] - status: Option, -} - -#[derive(Deserialize)] -struct Method { - name: String, - kind: String, // "op" | "query" - #[serde(default)] - op: Option, - #[serde(default)] - query: Option, - #[serde(default)] - args: Vec, - #[serde(default)] - returns: Option, - #[serde(default)] - fixed: Option, - #[serde(default)] - status: Option, -} - -#[derive(Deserialize)] -struct Arg { - name: String, - ty: String, -} - -#[derive(Deserialize)] -struct KernelMap { - op: String, - kernel: String, - returns: String, -} - -fn main() { - let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); - let spec_path = manifest_dir.join("spec/entities.toml"); - println!("cargo:rerun-if-changed={}", spec_path.display()); - - println!("cargo:rerun-if-changed=src/gen/layouts.json"); - let spec_src = fs::read_to_string(&spec_path).expect("read spec/entities.toml"); - let spec: Spec = toml::from_str(&spec_src).expect("parse spec/entities.toml"); - - let gen_dir = manifest_dir.join("src/gen"); - fs::create_dir_all(&gen_dir).unwrap(); - - write_if_changed(&gen_dir.join("api_reference.md"), &api_reference_md(&spec)); - - write_if_changed( - &gen_dir.join("binding_schema.json"), - &binding_schema_json(&spec), - ); -} - -/// Rewrite a generated file only when content changed (keeps mtime/CI stable). -fn write_if_changed(path: &PathBuf, content: &str) { - let unchanged = fs::read_to_string(path) - .map(|old| old == content) - .unwrap_or(false); - if !unchanged { - fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display())); - } -} - -/// Render a method/constructor's `fixed` fields compactly (e.g. -/// `, op="Intersection", erase_sources=true`). Empty when no fixed fields. -fn fmt_fixed(fixed: Option<&toml::Value>) -> String { - let Some(fixed) = fixed else { - return String::new(); - }; - let Some(tbl) = fixed.as_table() else { - return String::new(); - }; - if tbl.is_empty() { - return String::new(); - } - let parts: Vec = tbl.iter().map(|(k, v)| format!("{k}={v}")).collect(); - format!("; fixed {}", parts.join(", ")) -} - -fn fmt_args(args: &[Arg]) -> String { - // Renders `(...)` with the args, or `()` for a zero-arg method. - if args.is_empty() { - return String::new(); - } - args.iter() - .map(|a| format!("{}: {}", a.name, a.ty)) - .collect::>() - .join(", ") -} - -/// The parenthesised argument list: `(a: T, b: U)` or `()` for zero args. -fn fmt_call_args(args: &[Arg]) -> String { - if args.is_empty() { - return "()".to_string(); - } - format!("({})", fmt_args(args)) -} - -/// The generic handle methods + cross-cutting surface (applies to every handle; -/// documented once here rather than per-family). -fn generic_reference_md() -> String { - r#"## Generic methods (every handle) - -Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, -`Ellipse`, `Spline`, `Ray`, `XLine`, `Text`, `MText`, `Dimension`, `Entity`) is a -`Clone`, `Send`, `Sync` token `{ ObjectId, session }` and supports: - -- **`id()` -> `ObjectId`** — the stable entity identity. -- **`bounds()` -> `Aabb`** — coarse bounding box (solids lift-on-miss; 2D entities - computed; later families may be `Unsupported`). -- **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1: - solids only.** -- **`delete()`** — remove the entity (one undo step). -- **`layer()` -> `String`** — the name of the layer the entity is on. -- **`set_layer(layer)`** — move the entity to an existing layer (one undo step; - blocked if the entity or target layer is locked). - -`Entity` additionally has `view()` (id + kind + bounds), `as_solid()` (typed -downcast when `kind == "Solid3D"`), and XDATA access through -`xdata(application_name)` / `set_xdata(application_name, record)`. - -## Layer table - -Layers are a named document table, not an entity family. `Document::layers()` -lists all layers as `Vec`. Table-level ops are `CreateLayer(info)`, -`UpdateLayer { name, info }` and `DeleteLayer { name }`. `Entity::layer()` -returns the layer name and `Entity::set_layer(layer)` moves the entity to an -existing layer. The host rejects duplicate names, deletion of layer "0" or the -current layer, and removing a layer that still has entities assigned. - -## Transports - -- **`InProcess`** (feature `host`) — drives a `DocApiBackend` directly in the host. -- **`OcsPluginApiIpc`** (feature `ipc`) — serializes envelopes over the - `ocs_plugin_api` `PluginRequestSender`/`PluginRequest` channel. The host routes - `DocApiRequest { tab_id, bytes }` to the same executor. -- **`doc_api_for_host(host)`** (feature `doc_api_host`) — convenience helper that - builds a `DocApi` from any `ocs_plugin_api::host::HostApi` exposing a - `PluginRequestSender` (out-of-process / worker-thread plugins). - -## Collections & cross-cutting - -- **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`. -- **`Document`** — `solids()` / `curves()` / `entities()` factories + lookup, - `revision()`, `assert_revision(rev)` (read-guard), `query_batch(|q| …)` (read-only - batch, one round-trip, **no revision bump**). -- **`EntityCollection`** — `get(id) -> Entity`, `delete(id)`, - `transform_many(&ids, placement)`, `delete_many(&ids)`. -- **`OpGroup`** — best-effort client-side failure cleanup (`track` / `commit` / - `compensate`). **Not** a transaction: no atomicity, no rollback. - -## Bulk ops — single op, all-or-nothing, item-capped - -`CreateMany` / `TransformMany` / `DeleteMany` are **single** write ops: one undo -step, one publish, and **all-or-nothing via upfront validation** (no rollback). -Item-capped at `BULK_ITEM_CAP = 100_000` to bound host execution. Use them for -bulk data (N ≳ a few hundred); per-op autocommit is the right tool for -modeling-scale work. - -## Semantics - -- **Per-op autocommit, no write transactions.** Every write op is ONE atomic host - call = one undo step. Multi-op write transactions were removed (security/ - integrity review). -- **Per-op atomicity.** Validate + compute geometry **before** any mutation; a - failed op applies nothing and records no undo step. `erase_sources` booleans run - only on success. -- **Structured errors.** `ApiError::{Validation, Geometry, UnknownId, Unsupported, - Transport}`; bulk validation names the failing index; a stale handle returns - `UnknownId` (never a panic). - -"# - .to_string() -} - -fn status_suffix(status: Option<&str>) -> &'static str { - match status { - Some("unsupported") => " — **not yet supported** (returns `ApiError::Unsupported`)", - Some("planned") => " — **planned** (no callable surface yet)", - _ => "", - } -} - -fn api_reference_md(spec: &Spec) -> String { - let mut s = String::new(); - s.push_str("# ocs_doc_api — API reference (auto-generated)\n\n"); - s.push_str(&format!( - "Envelope version: `{}`\n\n", - spec.meta.envelope_version - )); - s.push_str("Do NOT edit: regenerated by `build.rs` from `spec/entities.toml`.\n\n"); - - for fam in &spec.family { - let collection = fam.collection.as_deref().unwrap_or("-"); - let handle = fam.handle.as_deref().unwrap_or(&fam.name); - s.push_str(&format!( - "## `{}` (acadrust `{}`, collection `{}`)\n\n", - handle, - fam.acadrust_variant.as_deref().unwrap_or("-"), - collection - )); - if let Some(d) = &fam.description { - s.push_str(&format!("{d}\n\n")); - } - - if !fam.constructor.is_empty() { - s.push_str(&format!("### Constructors (`doc.{collection}()`)\n\n")); - for c in &fam.constructor { - let ret = c.returns.as_deref().unwrap_or(handle); - let bulk = if c.bulk { " *(bulk op)*" } else { "" }; - let op = c.op.as_deref().unwrap_or("-"); - let fixed = fmt_fixed(c.fixed.as_ref()); - s.push_str(&format!( - "- **{}**{} -> `{ret}`{bulk} — `{op}`{fixed}{}\n", - c.name, - fmt_call_args(&c.args), - status_suffix(c.status.as_deref()) - )); - } - s.push('\n'); - } - - if !fam.method.is_empty() { - s.push_str("### Methods\n\n"); - for m in &fam.method { - let target = m.op.as_deref().or(m.query.as_deref()).unwrap_or("-"); - let fixed = fmt_fixed(m.fixed.as_ref()); - s.push_str(&format!( - "- **{}**{} -> `{}` — {} `{}`{fixed}{}\n", - m.name, - fmt_call_args(&m.args), - m.returns.as_deref().unwrap_or("()"), - m.kind, - target, - status_suffix(m.status.as_deref()) - )); - } - s.push('\n'); - } - } - - s.push_str(&generic_reference_md()); - - s.push_str("## Kernel mapping\n\n| op | kernel call | returns |\n|---|---|---|\n"); - for k in &spec.kernel_map { - s.push_str(&format!("| {} | `{}` | {} |\n", k.op, k.kernel, k.returns)); - } - s.push('\n'); - s.push_str(&wire_vocabulary_md()); - s.push('\n'); - s.push_str(&error_and_envelope_md()); - s -} - -/// The wire vocabulary section: every `Operation`/`Query` variant with its payload, -/// read from `src/gen/layouts.json` (the curated wire-layout snapshot). -fn wire_vocabulary_md() -> String { - let layouts = read_layouts(); - let mut s = String::from("## Wire vocabulary\n\nEvery `Operation` is ONE atomic write op (one undo step); every `Query` is read-only (safe to batch). Variants are append-only (bincode discriminant stability). Requests ride `DocApiEnvelope`: `Op(Operation)` for a write, `Queries(Vec)` for a read batch.\n\n"); - s.push_str("### `Operation`\n\n"); - if let Some(variants) = layouts - .get("Operation") - .and_then(|o| o.get("enum")) - .and_then(|v| v.as_array()) - { - for v in variants { - if let Some(name) = v.as_str() { - s.push_str(&format!("- `{name}`\n")); - } - } - } - s.push_str("\n### `Query`\n\n"); - if let Some(variants) = layouts - .get("Query") - .and_then(|o| o.get("enum")) - .and_then(|v| v.as_array()) - { - for v in variants { - if let Some(name) = v.as_str() { - s.push_str(&format!("- `{name}`\n")); - } - } - } - s -} - -/// The error model + envelope/transport section. -fn error_and_envelope_md() -> String { - r#"## Errors (`ApiError`) - -| Variant | Meaning | -|---|---| -| `Validation { op, reason }` | Input rejected before any document mutation. | -| `Geometry { kind, msg }` | The kernel failed (boolean refused, invalid input, ACIS lift/lower). | -| `UnknownId(ObjectId)` | The handle is stale / deleted / never existed (never a panic). | -| `Unsupported(String)` | Capability not supported for this entity or backend. | -| `Transport(String)` | The channel failed (disconnected / oversized / timeout). | - -`ApiError` is serializable, so IPC returns the same structured error the in-process -executor produced. - -## Envelope & transport - -`DocApiEnvelope { version, body }` — `body` is `Op(Operation)` (a single write) or -`Queries(Vec)` (a read batch). bincode-serialized; `version` is the crate's -envelope version (bridges must check it). Transports: `InProcess` (host) and -`OcsPluginApiIpc` (plugin over `ocs_plugin_api`), both behind the `Transport: -Send + Sync` trait (`apply(envelope) -> Receipt`, `alive()`). `Receipt` carries -`outcome` (per-op result), `query_results`, and `new_revision`. -"# - .to_string() -} - -/// The merged, self-contained binding handover schema (§10.2). Object model + -/// method signatures + op/query mapping + curated wire vocabulary inlined per node. -/// The handle types for the binding schema, derived from the spec's families -/// ("Entity" always first, then the deduped family handles in spec order). -fn handles_for_schema(spec: &Spec) -> Vec { - let mut out = vec!["Entity".to_string()]; - for fam in &spec.family { - let h = fam.handle.as_deref().unwrap_or(&fam.name); - if h != "Entity" && !out.iter().any(|x| x == h) { - out.push(h.to_string()); - } - } - out -} - -fn binding_schema_json(spec: &Spec) -> String { - let registry = read_layouts(); - - let families: Vec = spec - .family - .iter() - .map(|fam| { - let methods: Vec = fam - .method - .iter() - .map(|m| { - serde_json::json!({ - "name": m.name, - "kind": m.kind, - "op": m.op, - "query": m.query, - "args": m.args.iter().map(|a| serde_json::json!({"name": a.name, "ty": a.ty})).collect::>(), - "returns": m.returns, - "bulk": m.op.as_deref() == Some("CreateMany"), - "read_only": m.kind == "query", - "fixed": m.fixed, - }) - }) - .collect(); - serde_json::json!({ - "name": fam.name, - "handle": fam.handle, - "collection": fam.collection, - "acadrust_variant": fam.acadrust_variant, - "methods": methods, - "constructors": fam.constructor.iter().map(|c| serde_json::json!({ - "name": c.name, "op": c.op, "fixed": c.fixed, "bulk": c.bulk, - "returns": c.returns.as_deref().unwrap_or(fam.handle.as_deref().unwrap_or(&fam.name)), - "args": c.args.iter().map(|a| serde_json::json!({"name": a.name, "ty": a.ty})).collect::>() - })).collect::>(), - }) - }) - .collect(); - - let schema = serde_json::json!({ - "schema": "ocs_doc_api/binding_schema", - "envelope_version": spec.meta.envelope_version, - "object_model": { - "root": "DocApi", - "document": "Document", - "collections": ["solids", "curves", "entities"], - // Derived from the spec's family handles (deduped, "Entity" first) so - // new handle families appear in the handover automatically. - "handles": handles_for_schema(spec), - }, - "families": families, - // Curated wire vocabulary (not a complete binary codec schema). - "layouts": registry, - }); - - serde_json::to_string_pretty(&schema).expect("serialize binding schema") -} - -/// Read the manually maintained wire vocabulary; malformed snapshots fail the build. -fn read_layouts() -> serde_json::Value { - let src = fs::read_to_string(layouts_snapshot_path()).expect("read layouts.json"); - serde_json::from_str(&src).expect("parse layouts.json") -} - -fn layouts_snapshot_path() -> PathBuf { - PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("src/gen/layouts.json") -} diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs deleted file mode 100644 index 8b7b7454b..000000000 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ /dev/null @@ -1,710 +0,0 @@ -//! Shared in-memory `DocApiBackend` for the runnable examples. -//! -//! Lets the examples run without the host binary: an in-process mock document -//! (HashMap + a kind table) behind the `InProcess` transport. -//! Not for production use — the real backend lives in the host (`src/app/doc_api.rs`). - -use std::collections::HashMap; - -use ocs_doc_api::backend::{DocApiBackend, KernelBody}; -use ocs_doc_api::{ - normalize_layer_name, Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, - GeometryRevision, LayerInfo, ObjectId, PlacementSpec, -}; - - -#[derive(Clone)] -pub struct MockBackend { - next_id: u64, - revision: u64, - undo_steps: u32, - bodies: HashMap, - kinds: HashMap, - /// Stored 2D curve specs so `bounds()` works on non-solid entities too. - curves: HashMap, - /// Stored text content for Text/MText annotations. - text_values: HashMap, - /// Stored hatch boundary loops. - hatch_boundaries: HashMap>>, - /// Stored dimension measurements. - dimension_measurements: HashMap, - /// Stored insert attributes. - attributes_store: HashMap>, - /// Stored viewport views (target, height). - viewport_views: HashMap, - /// Stored XDATA records keyed by (entity id, application name). - xdata_store: HashMap<(ObjectId, String), ocs_doc_api::XDataRecord>, - /// Stored XRECORD payloads keyed by entity id. - xrecord_store: HashMap, - /// Stored layer table. - layers: HashMap, - /// Layer name assigned to each entity. - entity_layers: HashMap, -} - -impl Default for MockBackend { - fn default() -> Self { - let mut backend = Self { - next_id: 0, - revision: 0, - undo_steps: 0, - bodies: HashMap::new(), - kinds: HashMap::new(), - curves: HashMap::new(), - text_values: HashMap::new(), - hatch_boundaries: HashMap::new(), - dimension_measurements: HashMap::new(), - attributes_store: HashMap::new(), - viewport_views: HashMap::new(), - xdata_store: HashMap::new(), - xrecord_store: HashMap::new(), - layers: HashMap::new(), - entity_layers: HashMap::new(), - }; - backend.layers.insert("0".to_string(), LayerInfo::new("0")); - backend - } -} - -impl MockBackend { - fn alloc(&mut self, kind: &str) -> ObjectId { - self.next_id += 1; - let id = ObjectId::from_u64(self.next_id); - self.kinds.insert(id, kind.to_string()); - id - } - fn body(&self, id: ObjectId) -> ApiResult<&KernelBody> { - self.bodies.get(&id).ok_or(ApiError::UnknownId(id)) - } - #[allow(dead_code)] - pub fn undo_steps(&self) -> u32 { - self.undo_steps - } -} - -/// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds). -fn curve_bounds(spec: &Curve2Spec) -> Aabb { - match spec { - Curve2Spec::Line { start, end, .. } => Aabb { - min: [ - start[0].min(end[0]), - start[1].min(end[1]), - start[2].min(end[2]), - ], - max: [ - start[0].max(end[0]), - start[1].max(end[1]), - start[2].max(end[2]), - ], - }, - Curve2Spec::Circle { centre, radius, .. } => Aabb { - min: [centre[0] - radius, centre[1] - radius, centre[2]], - max: [centre[0] + radius, centre[1] + radius, centre[2]], - }, - Curve2Spec::Point { position, .. } => Aabb { - min: *position, - max: *position, - }, - Curve2Spec::Polyline { points, .. } => { - let mut min = [f64::INFINITY; 3]; - let mut max = [f64::NEG_INFINITY; 3]; - for p in points { - for i in 0..3 { - min[i] = min[i].min(p[i]); - max[i] = max[i].max(p[i]); - } - } - Aabb { min, max } - } - Curve2Spec::Arc { centre, radius, .. } => Aabb { - min: [centre[0] - radius, centre[1] - radius, centre[2]], - max: [centre[0] + radius, centre[1] + radius, centre[2]], - }, - Curve2Spec::Ellipse { - centre, major_axis, .. - } => { - let r = (major_axis[0] * major_axis[0] - + major_axis[1] * major_axis[1] - + major_axis[2] * major_axis[2]) - .sqrt(); - Aabb { - min: [centre[0] - r, centre[1] - r, centre[2] - r], - max: [centre[0] + r, centre[1] + r, centre[2] + r], - } - } - Curve2Spec::Spline { control_points, .. } => { - let mut min = [f64::INFINITY; 3]; - let mut max = [f64::NEG_INFINITY; 3]; - for p in control_points { - for i in 0..3 { - min[i] = min[i].min(p[i]); - max[i] = max[i].max(p[i]); - } - } - Aabb { min, max } - } - Curve2Spec::Ray { origin, .. } | Curve2Spec::XLine { origin, .. } => Aabb { - min: *origin, - max: *origin, - }, - } -} - -impl DocApiBackend for MockBackend { - fn resolve_body(&mut self, id: ObjectId) -> ApiResult { - Ok(self.body(id)?.clone()) - } - fn store_solid(&mut self, body: &KernelBody) -> ApiResult { - let id = self.alloc("Solid3D"); - self.bodies.insert(id, body.clone()); - Ok(id) - } - fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> { - if self.bodies.insert(id, body.clone()).is_none() { - return Err(ApiError::UnknownId(id)); - } - Ok(()) - } - fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { - // Validate per-spec layers up front so the op is atomic. - for spec in specs { - if let Some(name) = spec.layer() { - self.lookup_layer(name).map_err(|e| ApiError::validation( - "CreateMany", - e.to_string(), - ))?; - } - } - let mut next = self.clone(); - let ids = specs - .iter() - .map(|spec| match spec { - ocs_doc_api::EntitySpec::Solid(spec) => { - next.store_solid(&ocs_doc_api::geom::make_solid(spec)?) - } - ocs_doc_api::EntitySpec::Curve(spec) => { - ocs_doc_api::convert::curve_spec_to_entity(spec)?; - next.add_curve(spec) - } - }) - .collect::>>()?; - next.push_undo("CreateMany"); - next.finalize_op(); - *self = next; - Ok(ids) - } - fn transform_many(&mut self, ids: &[ObjectId], placement: &PlacementSpec) -> ApiResult<()> { - let mut next = self.clone(); - for &id in ids { - next.transform_entity(id, placement)?; - } - next.push_undo("TransformMany"); - next.finalize_op(); - *self = next; - Ok(()) - } - fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - let kind = match spec { - Curve2Spec::Line { .. } => "Line", - Curve2Spec::Circle { .. } => "Circle", - Curve2Spec::Polyline { .. } => "LwPolyline", - Curve2Spec::Point { .. } => "Point", - Curve2Spec::Arc { .. } => "Arc", - Curve2Spec::Ellipse { .. } => "Ellipse", - Curve2Spec::Spline { .. } => "Spline", - Curve2Spec::Ray { .. } => "Ray", - Curve2Spec::XLine { .. } => "XLine", - }; - let layer = spec - .layer() - .map(|name| { - self.lookup_layer(name).map(|info| info.normalized_name()) - }) - .transpose()?; - let id = self.alloc(kind); - self.curves.insert(id, spec.clone()); - self.entity_layers - .insert(id, layer.unwrap_or_else(|| "0".to_string())); - Ok(id) - } - - fn lookup_layer(&self, name: &str) -> ApiResult { - let norm = normalize_layer_name(name); - if norm.is_empty() { - return Err(ApiError::validation("LookupLayer", "empty layer name")); - } - self.layers - .get(&norm) - .cloned() - .ok_or_else(|| ApiError::validation("LookupLayer", format!("layer '{name}' does not exist"))) - } - fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { - if spec.block_name.is_empty() { - return Err(ApiError::validation("CreateInsert", "empty block name")); - } - Ok(self.alloc("Insert")) - } - fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { - let id = self.alloc("Viewport"); - self.viewport_views - .insert(id, (spec.view_target, spec.view_height)); - Ok(id) - } - fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { - let id = self.alloc("Text"); - self.text_values.insert(id, spec.value.clone()); - Ok(id) - } - fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult { - let id = self.alloc("MText"); - self.text_values.insert(id, spec.value.clone()); - Ok(id) - } - fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { - if let std::collections::hash_map::Entry::Occupied(mut e) = self.text_values.entry(id) { - e.insert(value.to_string()); - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - fn text_content(&self, id: ObjectId) -> ApiResult { - self.text_values - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult { - let id = self.alloc("Hatch"); - self.hatch_boundaries - .insert(id, vec![spec.boundary.clone()]); - Ok(id) - } - fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { - self.hatch_boundaries - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn add_dimension_linear( - &mut self, - spec: &ocs_doc_api::ops::DimensionSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - let d = ((spec.first_point[0] - spec.second_point[0]).powi(2) - + (spec.first_point[1] - spec.second_point[1]).powi(2) - + (spec.first_point[2] - spec.second_point[2]).powi(2)) - .sqrt(); - self.dimension_measurements.insert(id, d); - Ok(id) - } - fn add_dimension_radius( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - let d = ((spec.center[0] - spec.point[0]).powi(2) - + (spec.center[1] - spec.point[1]).powi(2) - + (spec.center[2] - spec.point[2]).powi(2)) - .sqrt(); - self.dimension_measurements.insert(id, d); - Ok(id) - } - fn add_dimension_diameter( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - self.add_dimension_radius(spec) - } - fn add_dimension_angular( - &mut self, - _spec: &ocs_doc_api::ops::DimensionAngularSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - self.dimension_measurements.insert(id, 90.0); - Ok(id) - } - fn dimension_measurement(&self, id: ObjectId) -> ApiResult { - self.dimension_measurements - .get(&id) - .copied() - .ok_or(ApiError::UnknownId(id)) - } - fn add_attribute_definition( - &mut self, - spec: &ocs_doc_api::ops::AttributeDefinitionSpec, - ) -> ApiResult { - if spec.tag.is_empty() { - return Err(ApiError::validation( - "CreateAttributeDefinition", - "empty tag", - )); - } - Ok(self.alloc("AttributeDefinition")) - } - fn add_dimension_angular2ln( - &mut self, - _spec: &ocs_doc_api::ops::DimensionAngularSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - self.dimension_measurements.insert(id, 90.0); - Ok(id) - } - fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { - let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); - if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) { - return Err(ApiError::validation( - "CreateTable", - "table needs a non-empty rectangular grid", - )); - } - Ok(self.alloc("Table")) - } - fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let attrs = self.attributes_store.entry(id).or_default(); - if let Some(a) = attrs.iter_mut().find(|(t, _)| t == tag) { - a.1 = value.to_string(); - } else { - attrs.push((tag.to_string(), value.to_string())); - } - Ok(()) - } - fn attributes(&self, id: ObjectId) -> ApiResult> { - self.attributes_store - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn block_entities(&self, _block_name: &str) -> ApiResult> { - Ok(Vec::new()) - } - fn add_raster_image( - &mut self, - _spec: &ocs_doc_api::ops::RasterImageSpec, - ) -> ApiResult { - Ok(self.alloc("RasterImage")) - } - fn loft( - &mut self, - sections: &[(cadkernel::space::Plane, Vec)], - ) -> ApiResult { - if sections.len() < 2 { - return Err(ApiError::validation("Loft", "loft needs >= 2 profiles")); - } - let body = cadkernel::brep::make::cuboid([0.0, 0.0, 0.0], [1.0; 3]) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "make cuboid"))?; - self.store_solid(&body) - } - fn set_viewport_view( - &mut self, - id: ObjectId, - view_target: [f64; 3], - view_height: f64, - ) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - self.viewport_views.insert(id, (view_target, view_height)); - Ok(()) - } - fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> { - self.viewport_views - .get(&id) - .copied() - .ok_or(ApiError::UnknownId(id)) - } - fn set_xdata( - &mut self, - id: ObjectId, - application_name: &str, - record: Option<&ocs_doc_api::XDataRecord>, - ) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let key = (id, application_name.to_string()); - match record { - Some(r) => { - self.xdata_store.insert(key, r.clone()); - } - None => { - self.xdata_store.remove(&key); - } - } - Ok(()) - } - fn xdata( - &self, - id: ObjectId, - application_name: &str, - ) -> ApiResult> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - Ok(self - .xdata_store - .get(&(id, application_name.to_string())) - .cloned()) - } - fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult { - let id = self.alloc("XRecord"); - self.xrecord_store.insert(id, spec.clone()); - Ok(id) - } - fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "SetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - self.xrecord_store.insert(id, spec.clone()); - Ok(()) - } - fn xrecord(&self, id: ObjectId) -> ApiResult> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "GetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - Ok(self.xrecord_store.get(&id).cloned()) - } - fn create_layer(&mut self, info: &LayerInfo) -> ApiResult<()> { - let name = normalize_layer_name(&info.name); - if name.is_empty() { - return Err(ApiError::validation("CreateLayer", "empty layer name")); - } - if self.layers.contains_key(&name) { - return Err(ApiError::validation( - "CreateLayer", - format!("layer '{name}' already exists"), - )); - } - let mut stored = info.clone(); - stored.name = name.clone(); - self.layers.insert(name, stored); - Ok(()) - } - fn update_layer(&mut self, name: &str, info: &LayerInfo) -> ApiResult<()> { - let name_upper = normalize_layer_name(name); - if !self.layers.contains_key(&name_upper) { - return Err(ApiError::validation( - "UpdateLayer", - format!("layer '{name}' does not exist"), - )); - } - let mut stored = info.clone(); - stored.name = name_upper.clone(); - self.layers.insert(name_upper, stored); - Ok(()) - } - fn delete_layer(&mut self, name: &str) -> ApiResult<()> { - let name_upper = normalize_layer_name(name); - if name_upper == "0" { - return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0")); - } - if self.entity_layers.values().any(|l| normalize_layer_name(l) == name_upper) { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name_upper}' still in use"), - )); - } - if self.layers.remove(&name_upper).is_none() { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name}' does not exist"), - )); - } - Ok(()) - } - fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let layer_upper = normalize_layer_name(layer); - let target = self - .layers - .get(&layer_upper) - .ok_or_else(|| { - ApiError::validation( - "SetEntityLayer", - format!("layer '{layer_upper}' does not exist"), - ) - })?; - if target.flags.locked { - return Err(ApiError::Unsupported(format!( - "entity {id:?} cannot be moved to locked layer '{layer_upper}'" - ))); - } - self.entity_layers.insert(id, layer_upper); - Ok(()) - } - fn layers(&self) -> ApiResult> { - let mut v: Vec<_> = self.layers.values().cloned().collect(); - v.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(v) - } - fn entity_layer(&self, id: ObjectId) -> ApiResult { - self.entity_layers - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { - if self.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - fn remove_entity(&mut self, id: ObjectId) -> ApiResult { - self.bodies.remove(&id); - self.xdata_store.retain(|(k, _), _| *k != id); - self.xrecord_store.remove(&id); - self.entity_layers.remove(&id); - Ok(self.kinds.remove(&id).is_some()) - } - fn enumerate_entities( - &self, - kind: Option<&str>, - layer: Option<&str>, - include_bounds: bool, - ) -> ApiResult> { - let mut out = Vec::new(); - for (id, kind_name) in &self.kinds { - if let Some(k) = kind { - if !k.eq_ignore_ascii_case(kind_name) { - continue; - } - } - let entity_layer = self - .entity_layers - .get(id) - .cloned() - .unwrap_or_else(|| "0".to_string()); - if let Some(l) = layer { - if !l.eq_ignore_ascii_case(&entity_layer) { - continue; - } - } - let bounds = if include_bounds { - self.curves.get(id).map(curve_bounds) - } else { - None - }; - out.push(EntityView { - id: *id, - kind: kind_name.clone(), - layer: entity_layer, - bounds, - }); - } - Ok(out) - } - fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> { - let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; - match spec { - Curve2Spec::Point { position, .. } => Ok(*position), - _ => Err(ApiError::Unsupported( - "GetPointPosition is only for Point entities".into(), - )), - } - } - fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> { - let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; - match spec { - Curve2Spec::Line { start, end, .. } => Ok((*start, *end)), - _ => Err(ApiError::Unsupported( - "GetLineGeometry is only for Line entities".into(), - )), - } - } - fn get_entity(&mut self, id: ObjectId) -> ApiResult { - let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone(); - let layer = self - .entity_layers - .get(&id) - .cloned() - .unwrap_or_else(|| "0".to_string()); - let bounds = self.bodies.get(&id).and_then(|b| { - cadkernel::brep::body_bounds(b).map(|bb| Aabb { - min: bb.min, - max: bb.max, - }) - }); - Ok(EntityView { - id, - kind, - layer, - bounds, - }) - } - fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - if let Some(body) = self.bodies.get(&id).cloned() { - let place = cadkernel::brep::Placement { - x_axis: placement.x_axis, - y_axis: placement.y_axis, - z_axis: placement.z_axis, - origin: placement.origin, - }; - let moved = cadkernel::brep::transform(&body, &place).ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed") - })?; - self.bodies.insert(id, moved); - } - Ok(()) - } - fn profile_curves(&self, _id: ObjectId) -> ApiResult> { - Err(ApiError::Unsupported("profiles not mocked".into())) - } - fn bounds(&mut self, id: ObjectId) -> ApiResult { - // Solids via the kernel body; 2D curves from their stored spec. - if let Some(body) = self.bodies.get(&id) { - let bb = cadkernel::brep::body_bounds(body) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?; - return Ok(Aabb { - min: bb.min, - max: bb.max, - }); - } - if let Some(spec) = self.curves.get(&id) { - return Ok(curve_bounds(spec)); - } - Err(ApiError::UnknownId(id)) - } - fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { - let body = self.body(id)?; - Ok(mesh_vc(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).1) - } - fn volume(&mut self, id: ObjectId) -> ApiResult { - let body = self.body(id)?; - Ok(mesh_vc(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).0) - } - fn entity_exists(&self, id: ObjectId) -> bool { - self.kinds.contains_key(&id) - } - fn object_exists(&self, id: ObjectId) -> bool { - self.xrecord_store.contains_key(&id) - } - fn revision(&self) -> GeometryRevision { - GeometryRevision(self.revision) - } - fn push_undo(&mut self, _label: &str) {} - fn finalize_op(&mut self) { - self.undo_steps += 1; - self.revision += 1; - } -} - -/// Signed volume + centroid of a closed mesh — delegates to the crate's single -/// source of truth (divergence theorem) so the example mock can't drift. -fn mesh_vc(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { - ocs_doc_api::geom::mesh_volume_centroid(mesh) -} diff --git a/crates/ocs_doc_api/examples/e1_primitives_and_queries.rs b/crates/ocs_doc_api/examples/e1_primitives_and_queries.rs deleted file mode 100644 index 3d3312f11..000000000 --- a/crates/ocs_doc_api/examples/e1_primitives_and_queries.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Example 1 — Create solid primitives and query their geometry. -//! -//! Run: `cargo run --example e1_primitives_and_queries --features host` -//! -//! Shows: `doc.solids().create_*` for the supported primitives, and the read -//! queries `bounds()` / `volume()` / `centroid()` on a `Solid`. - -mod common; - -use ocs_doc_api::DocApi; - -fn main() -> ocs_doc_api::ApiResult<()> { - // In-process document over the mock backend (no host binary needed). - let api = DocApi::in_process(common::MockBackend::default(), 0); - let doc = api.document(api.active_tab()); - - // The supported solid primitives. - let block = doc - .solids() - .create_cuboid([0.0, 0.0, 0.0], [10.0, 10.0, 10.0])?; - let ball = doc.solids().create_sphere([5.0, 5.0, 5.0], 6.0)?; - let cyl = doc.solids().create_cylinder([0.0, 0.0, 0.0], 4.0, 12.0)?; - - // Geometric queries are entity methods — one host call each. - let bb = block.bounds()?; - let vol = ball.volume()?; - let cg = cyl.centroid()?; - - println!("cuboid bounds : {bb:?}"); - println!("sphere volume : {vol:.2} (analytic ~904.78)"); - println!("cylinder centroid: {cg:?}"); - - // Read-only batching: same queries, ONE round-trip. - let res = doc.query_batch(|q| { - q.bounds(&block); - q.volume(&ball); - q.centroid(&cyl); - })?; - let (bb2, vol2, cg2) = (res.bounds(0)?, res.volume(1)?, res.centroid(2)?); - assert_eq!(bb, bb2); - assert!((vol - vol2).abs() < 1e-9); - assert_eq!(cg, cg2); - println!("query_batch matches the per-call results"); - Ok(()) -} diff --git a/crates/ocs_doc_api/examples/e2_intersect_two_solids.rs b/crates/ocs_doc_api/examples/e2_intersect_two_solids.rs deleted file mode 100644 index dd117c6fd..000000000 --- a/crates/ocs_doc_api/examples/e2_intersect_two_solids.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Example 2 — Construct two solids and intersect them (the canonical boolean). -//! -//! Run: `cargo run --example e2_intersect_two_solids --features host` -//! -//! Shows: `intersect` / `union` / `subtract` on `Solid`, `erase_sources` -//! semantics (the result lives at the first input's id, the second is erased), -//! and `OpGroup` cleanup on failure. - -mod common; - -use ocs_doc_api::{DocApi, HasId, OpGroup}; - -fn main() -> ocs_doc_api::ApiResult<()> { - let api = DocApi::in_process(common::MockBackend::default(), 0); - let doc = api.document(api.active_tab()); - let mut grp = OpGroup::new(); - - // Two overlapping boxes: [0,10]^3 and [5,15]^3. - let a = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3]))?; - let b = grp.track(doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3]))?; - - // intersect -> SolidBoolean{Intersection, erase_sources:true}. The result is - // returned AT `a`'s id; `b` is erased. [5,10]^3 = volume 125. - let lens = match a.intersect(&b) { - Ok(l) => { - grp.commit(); // keep everything - l - } - Err(e) => { - grp.compensate(&doc)?; // best-effort: delete the two boxes - return Err(e); - } - }; - - assert_eq!(lens.id(), a.id(), "result lives at the first input's id"); - let vol = lens.volume()?; - let bb = lens.bounds()?; - println!("intersection volume = {vol:.2} (expected 125)"); - println!("intersection bounds = {bb:?}"); - - // union / subtract follow the same shape. Fresh inputs (b was erased above). - let c = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; - let d = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?; - let uni = c.union(&d)?; - // union volume = 1000 + 1000 - 125 (overlap) = 1875. - println!("union volume = {:.2} (expected 1875)", uni.volume()?); - - let e = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; - let f = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?; - let diff = e.subtract(&f)?; - println!("difference volume = {:.2} (expected 875)", diff.volume()?); - Ok(()) -} diff --git a/crates/ocs_doc_api/examples/e3_transform_and_chain.rs b/crates/ocs_doc_api/examples/e3_transform_and_chain.rs deleted file mode 100644 index 1229f2997..000000000 --- a/crates/ocs_doc_api/examples/e3_transform_and_chain.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Example 3 — Transform a solid and chain geometric operations. -//! -//! Run: `cargo run --example e3_transform_and_chain --features host` -//! -//! Shows: `transform(PlacementSpec)` (in-place, same ObjectId), how bounds shift -//! exactly by the translation, and chaining intersect -> transform. - -mod common; - -use ocs_doc_api::{DocApi, PlacementSpec}; - -fn main() -> ocs_doc_api::ApiResult<()> { - let api = DocApi::in_process(common::MockBackend::default(), 0); - let doc = api.document(api.active_tab()); - - let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; - let b = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?; - - // Boolean first: lens = [5,10]^3. - let lens = a.intersect(&b)?; - let before = lens.bounds()?; - println!("lens bounds before transform: {before:?}"); - - // Transform is in-place: the SAME ObjectId is preserved; bounds shift by the - // translation. [5,10]^3 moved +100 in X -> [105,110] x [5,10] x [5,10]. - lens.transform(PlacementSpec::at([100.0, 0.0, 0.0]))?; - let after = lens.bounds()?; - println!("lens bounds after +100 X : {after:?}"); - assert!((after.min[0] - 105.0).abs() < 1e-4); - assert!((after.max[0] - 110.0).abs() < 1e-4); - assert_eq!(after.min[1], before.min[1], "Y unchanged"); - - // Volume is invariant under a rigid transform. - let vol = lens.volume()?; - println!("lens volume after transform = {vol:.2} (still ~125)"); - assert!((vol - 125.0).abs() < 1.0); - Ok(()) -} diff --git a/crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs b/crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs deleted file mode 100644 index eb1ff0b5b..000000000 --- a/crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Example 4 — 2D entities and bulk creation. -//! -//! Run: `cargo run --example e4_2d_entities_and_bulk --features host` -//! -//! Shows: the minimal-2D constructors (`create_line`/`create_circle`/ -//! `create_polyline`/`create_point`), `bounds()` on 2D entities, and the bulk op -//! `create_points` (one op, all-or-nothing, one undo step). - -mod common; - -use ocs_doc_api::{DocApi, HasId}; - -fn main() -> ocs_doc_api::ApiResult<()> { - let api = DocApi::in_process(common::MockBackend::default(), 0); - let doc = api.document(api.active_tab()); - - // Individual 2D constructors. - let line = doc - .curves() - .create_line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])?; - let circle = doc.curves().create_circle([5.0, 5.0, 0.0], 3.0)?; - let poly = doc.curves().create_polyline( - &[ - [0.0, 0.0, 0.0], - [1.0, 0.0, 0.0], - [1.0, 1.0, 0.0], - [0.0, 1.0, 0.0], - ], - true, - )?; - - println!("line bounds: {:?}", line.bounds()?); // [0,10] in X - println!("circle bounds: {:?}", circle.bounds()?); // centre ± radius - println!("poly bounds: {:?}", poly.bounds()?); // unit square - - // Bulk: 1000 points in ONE op (all-or-nothing, one undo step — not 1000). - let coords: Vec<[f64; 3]> = (0..1000) - .map(|i| [i as f64, (i % 10) as f64, 0.0]) - .collect(); - let pts = doc.curves().create_points(&coords)?; - println!("created {} points in one bulk op", pts.len()); - assert_eq!(pts.len(), 1000); - - // Generic lookup: any id -> Entity -> kind. - let e = doc.entities().get(line.id())?; - println!("line entity kind: {}", e.view()?.kind); - - Ok(()) -} diff --git a/crates/ocs_doc_api/examples/e5_failure_and_guards.rs b/crates/ocs_doc_api/examples/e5_failure_and_guards.rs deleted file mode 100644 index 2cc82dbe5..000000000 --- a/crates/ocs_doc_api/examples/e5_failure_and_guards.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Example 5 — Failure semantics: atomicity, OpGroup compensation, revision guard. -//! -//! Run: `cargo run --example e5_failure_and_guards --features host` -//! -//! Shows: per-op atomicity (a failed op applies nothing and moves no revision), -//! `OpGroup` best-effort cleanup, `ApiError` handling, and the `assert_revision` -//! read-guard. - -mod common; - -use ocs_doc_api::{ApiError, DocApi, HasId, ObjectId, OpGroup}; - -fn main() -> ocs_doc_api::ApiResult<()> { - let api = DocApi::in_process(common::MockBackend::default(), 0); - let doc = api.document(api.active_tab()); - - // ── Per-op atomicity: a boolean against a deleted id fails cleanly ── - let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; - let ghost = doc.solids().create_sphere([5.0, 5.0, 5.0], 4.0)?; - ghost.delete()?; // now a stale handle - - let rev_before = doc.revision()?; - match a.intersect(&ghost) { - Err(ApiError::UnknownId(id)) => println!("boolean failed as expected: unknown id {id:?}"), - Err(e) => println!("boolean failed: {e}"), - Ok(_) => panic!("expected the boolean to fail"), - } - // `a` is still live (failed op mutated nothing) and the revision did not move. - assert!(a.bounds().is_ok(), "input solid survives a failed boolean"); - assert_eq!( - doc.revision()?, - rev_before, - "failed op records no revision bump" - ); - - // ── OpGroup: clean up a partially-built logical operation on failure ── - let mut grp = OpGroup::new(); - let p1 = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [4.0; 3]))?; - let p2 = grp.track(doc.solids().create_sphere([2.0, 2.0, 2.0], 3.0))?; - // Simulate a downstream failure: abandon the group and delete what we made. - grp.compensate(&doc)?; - for h in [p1.id(), p2.id()] { - assert!(doc.entities().get(h).is_err(), "compensated entity is gone"); - } - println!("OpGroup compensated: created entities deleted"); - - // ── assert_revision: read-modify-write guard ── - let rev = doc.revision()?; - doc.assert_revision(rev)?; // unchanged -> ok - doc.solids().create_cuboid([0.0, 0.0, 0.0], [1.0; 3])?; // moves the revision - match doc.assert_revision(rev) { - Err(ApiError::Validation { reason, .. }) => println!("revision guard caught: {reason}"), - other => panic!("expected the revision guard to fail, got {other:?}"), - } - - // A totally unknown id also surfaces a structured error. - assert!(doc.entities().get(ObjectId::from_u64(0xDEAD)).is_err()); - println!("failure semantics verified"); - Ok(()) -} diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml deleted file mode 100644 index 4de4cf6eb..000000000 --- a/crates/ocs_doc_api/spec/entities.toml +++ /dev/null @@ -1,566 +0,0 @@ -# Curated source of truth for ocs_doc_api codegen (plan §10, decision #6). -# Maps entity families + their construction ops, geometric actions, and queries -# to kernel fns / acadrust variants. build.rs regenerates src/gen/* from this; -# CI fails on drift. New op/query variants append at the END only (bincode -# discriminant stability). -# -# `status` values: "supported" (default), "unsupported" (present but errors), -# "planned" (roadmap; no callable surface). Generic handle methods -# (bounds/transform/delete) are documented once in build.rs and apply to every -# family unless overridden here. - -[meta] -envelope_version = 1 - -# ============================================================================ -# SOLID FAMILY (phase 1) — B-rep solids via cadkernel -# ============================================================================ -[[family]] -name = "Solid" -acadrust_variant = "Solid3D" -handle = "Solid" -collection = "solids" -description = "B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from ACIS SAT)." - -[[family.constructor]] -name = "create_cuboid" -args = [{ name = "origin", ty = "[f64; 3]" }, { name = "size", ty = "[f64; 3]" }] -op = "CreateSolid" -fixed = { primitive = "Cuboid" } - -[[family.constructor]] -name = "create_sphere" -args = [{ name = "centre", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }] -op = "CreateSolid" -fixed = { primitive = "Sphere" } - -[[family.constructor]] -name = "create_cylinder" -args = [{ name = "base", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }, { name = "height", ty = "f64" }] -op = "CreateSolid" -fixed = { primitive = "Cylinder" } - -[[family.constructor]] -name = "create_cone" -args = [{ name = "base", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }, { name = "height", ty = "f64" }] -op = "CreateSolid" -fixed = { primitive = "Cone" } - -[[family.constructor]] -name = "create_torus" -args = [{ name = "centre", ty = "[f64; 3]" }, { name = "major_radius", ty = "f64" }, { name = "minor_radius", ty = "f64" }] -op = "CreateSolid" -fixed = { primitive = "Torus" } - -[[family.constructor]] -name = "create_wedge" -args = [{ name = "origin", ty = "[f64; 3]" }, { name = "size", ty = "[f64; 3]" }] -op = "CreateSolid" -fixed = { primitive = "Wedge" } - -[[family.constructor]] -name = "extrude" -args = [{ name = "profile", ty = "EntityRef" }, { name = "direction", ty = "[f64; 3]" }] -op = "Extrude" - -[[family.constructor]] -name = "revolve" -args = [{ name = "profile", ty = "EntityRef" }, { name = "pivot", ty = "[f64; 3]" }, { name = "axis", ty = "[f64; 3]" }, { name = "angle", ty = "f64" }] -op = "Revolve" - -[[family.constructor]] -name = "loft" -args = [{ name = "profiles", ty = "&[EntityRef]" }] -op = "Loft" - -[[family.method]] -name = "intersect" -kind = "op" -op = "SolidBoolean" -args = [{ name = "other", ty = "Solid" }] -returns = "Solid" -fixed = { op = "Intersection", erase_sources = true } - -[[family.method]] -name = "union" -kind = "op" -op = "SolidBoolean" -args = [{ name = "other", ty = "Solid" }] -returns = "Solid" -fixed = { op = "Union", erase_sources = true } - -[[family.method]] -name = "subtract" -kind = "op" -op = "SolidBoolean" -args = [{ name = "other", ty = "Solid" }] -returns = "Solid" -fixed = { op = "Difference", erase_sources = true } - -[[family.method]] -name = "volume" -kind = "query" -query = "GetVolume" -returns = "f64" - -[[family.method]] -name = "centroid" -kind = "query" -query = "GetCentroid" -returns = "[f64; 3]" - -[[family.method]] -name = "intersects" -kind = "query" -query = "GetIntersects" -args = [{ name = "other", ty = "Solid" }] -returns = "bool" - -# ============================================================================ -# CURVE FAMILY (phase 1, minimal 2D) -# ============================================================================ -[[family]] -name = "Line" -acadrust_variant = "Line" -handle = "Line" -collection = "curves" - -[[family.constructor]] -name = "create_line" -args = [{ name = "start", ty = "[f64; 3]" }, { name = "end", ty = "[f64; 3]" }] -op = "CreateCurve" -fixed = { curve = "Line" } - -[[family]] -name = "Circle" -acadrust_variant = "Circle" -handle = "Circle" -collection = "curves" - -[[family.constructor]] -name = "create_circle" -args = [{ name = "centre", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }] -op = "CreateCurve" -fixed = { curve = "Circle" } - -[[family]] -name = "Polyline" -acadrust_variant = "LwPolyline" -handle = "Polyline" -collection = "curves" - -[[family.constructor]] -name = "create_polyline" -args = [{ name = "points", ty = "&[[f64; 3]]" }, { name = "closed", ty = "bool" }] -op = "CreateCurve" -fixed = { curve = "Polyline" } - -[[family.method]] -name = "add_vertex" -kind = "op" -op = "AddVertex" -args = [{ name = "at", ty = "usize" }, { name = "point", ty = "[f64; 3]" }] -returns = "()" - -[[family]] -name = "Point" -acadrust_variant = "Point" -handle = "Point" -collection = "curves" - -[[family.constructor]] -name = "create_point" -args = [{ name = "position", ty = "[f64; 3]" }] -op = "CreateCurve" -fixed = { curve = "Point" } - -[[family.constructor]] -name = "create_points" -args = [{ name = "positions", ty = "&[[f64; 3]]" }] -op = "CreateMany" -fixed = { curve = "Point" } -bulk = true -returns = "Vec" - -[[family]] -name = "Arc" -acadrust_variant = "Arc" -handle = "ArcCurve" -collection = "curves" - -[[family.constructor]] -name = "create_arc" -args = [{ name = "centre", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }, { name = "start_angle", ty = "f64" }, { name = "end_angle", ty = "f64" }] -op = "CreateCurve" -fixed = { curve = "Arc" } - -[[family]] -name = "Ellipse" -acadrust_variant = "Ellipse" -handle = "Ellipse" -collection = "curves" - -[[family.constructor]] -name = "create_ellipse" -args = [{ name = "centre", ty = "[f64; 3]" }, { name = "major_axis", ty = "[f64; 3]" }, { name = "ratio", ty = "f64" }, { name = "start", ty = "f64" }, { name = "end", ty = "f64" }] -op = "CreateCurve" -fixed = { curve = "Ellipse" } - -[[family]] -name = "Spline" -acadrust_variant = "Spline" -handle = "Spline" -collection = "curves" - -[[family.constructor]] -name = "create_spline" -args = [{ name = "degree", ty = "i32" }, { name = "control_points", ty = "&[[f64; 3]]" }, { name = "knots", ty = "&[f64]" }, { name = "weights", ty = "&[f64]" }] -op = "CreateCurve" -fixed = { curve = "Spline" } - -[[family]] -name = "Ray" -acadrust_variant = "Ray" -handle = "Ray" -collection = "curves" - -[[family.constructor]] -name = "create_ray" -args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64; 3]" }] -op = "CreateCurve" -fixed = { curve = "Ray" } - -[[family]] -name = "XLine" -acadrust_variant = "XLine" -handle = "XLine" -collection = "curves" - -[[family.constructor]] -name = "create_xline" -args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64; 3]" }] -op = "CreateCurve" -fixed = { curve = "XLine" } - -# ============================================================================ -# ANNOTATION FAMILY (phase 2b) — text annotations -# ============================================================================ -[[family]] -name = "Text" -acadrust_variant = "Text" -handle = "Text" -collection = "curves" -description = "Single-line text annotation. content()/set_content() read+replace the value; transform moves insertion point." - -[[family.constructor]] -name = "create_text" -args = [{ name = "value", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "height", ty = "f64" }, { name = "rotation", ty = "f64" }] -op = "CreateText" - -[[family.method]] -name = "content" -kind = "query" -query = "GetTextContent" -returns = "String" - -[[family.method]] -name = "set_content" -kind = "op" -op = "SetTextContent" -args = [{ name = "value", ty = "&str" }] -returns = "()" - -[[family]] -name = "MText" -acadrust_variant = "MText" -handle = "MText" -collection = "curves" -description = "Multi-line text annotation. content()/set_content() read+replace the value; transform moves insertion point." - -[[family.constructor]] -name = "create_mtext" -args = [{ name = "value", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "height", ty = "f64" }] -op = "CreateMText" - -[[family.method]] -name = "content" -kind = "query" -query = "GetTextContent" -returns = "String" - -[[family.method]] -name = "set_content" -kind = "op" -op = "SetTextContent" -args = [{ name = "value", ty = "&str" }] -returns = "()" - -# ============================================================================ -# HATCH FAMILY (phase 2b) — filled regions -# ============================================================================ -[[family]] -name = "Hatch" -acadrust_variant = "Hatch" -handle = "Entity" -collection = "curves" -description = "Filled region over a closed polyline boundary. boundary() returns the loops; bounds from boundary; generic delete." - -[[family.constructor]] -name = "create_hatch" -args = [{ name = "boundary", ty = "&[[f64; 2]]" }, { name = "solid", ty = "bool" }] -op = "CreateHatch" - -[[family.method]] -name = "boundary" -kind = "query" -query = "GetHatchBoundary" -returns = "Vec>" - -# ============================================================================ -# DIMENSION FAMILY (phase 2c) — linear dimensions -# ============================================================================ -[[family]] -name = "Dimension" -acadrust_variant = "Dimension" -handle = "Dimension" -collection = "curves" -description = "Linear dimension between two measured points. measurement() reads the distance; bounds from the measured + definition points." - -[[family.constructor]] -name = "create_dimension_linear" -args = [{ name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "definition_point", ty = "[f64; 3]" }] -op = "CreateDimensionLinear" - -[[family.constructor]] -name = "create_dimension_radius" -args = [{ name = "center", ty = "[f64; 3]" }, { name = "point", ty = "[f64; 3]" }] -op = "CreateDimensionRadius" - -[[family.constructor]] -name = "create_dimension_diameter" -args = [{ name = "center", ty = "[f64; 3]" }, { name = "point", ty = "[f64; 3]" }] -op = "CreateDimensionDiameter" - -[[family.constructor]] -name = "create_dimension_angular" -args = [{ name = "vertex", ty = "[f64; 3]" }, { name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "arc_location", ty = "[f64; 3]" }] -op = "CreateDimensionAngular" - -[[family.constructor]] -name = "create_dimension_angular2ln" -args = [{ name = "vertex", ty = "[f64; 3]" }, { name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "arc_location", ty = "[f64; 3]" }] -op = "CreateDimensionAngular2Ln" - -[[family.method]] -name = "measurement" -kind = "query" -query = "GetDimensionMeasurement" -returns = "f64" - -# ============================================================================ -# CONTAINER FAMILY (phase 3) — block references -# ============================================================================ -[[family]] -name = "Insert" -acadrust_variant = "Insert" -handle = "Entity" -collection = "entities" -description = "Block reference. Place an existing BlockRecord by name; transform moves its insert point." - -[[family.constructor]] -name = "create_insert" -args = [{ name = "block_name", ty = "&str" }, { name = "insert_point", ty = "[f64; 3]" }, { name = "scale", ty = "f64" }, { name = "rotation", ty = "f64" }] -op = "CreateInsert" - -[[family.method]] -name = "attributes" -kind = "query" -query = "GetAttributes" -returns = "Vec<(String, String)>" - -[[family.method]] -name = "set_attribute" -kind = "op" -op = "SetAttribute" -args = [{ name = "tag", ty = "&str" }, { name = "value", ty = "&str" }] -returns = "()" - -# ============================================================================ -# ATTRIBUTE-DEFINITION FAMILY (phase 3-ii) — in-block attribute definitions -# ============================================================================ -[[family]] -name = "AttributeDefinition" -acadrust_variant = "AttributeDefinition" -handle = "Entity" -collection = "entities" -description = "In-block attribute definition (ATTDEF): tag/prompt/default at an insertion point." - -[[family.constructor]] -name = "create_attribute_definition" -args = [{ name = "tag", ty = "&str" }, { name = "prompt", ty = "&str" }, { name = "default_value", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "height", ty = "f64" }, { name = "rotation", ty = "f64" }] -op = "CreateAttributeDefinition" - -# ============================================================================ -# PAPER-SPACE FAMILY (phase 4) — viewports -# ============================================================================ -[[family]] -name = "Viewport" -acadrust_variant = "Viewport" -handle = "Entity" -collection = "entities" -description = "Paper-space viewport: a width×height window at center looking at view_target with view_height zoom. bounds = center ± w/2,h/2; transform moves center." - -[[family.constructor]] -name = "create_viewport" -args = [{ name = "center", ty = "[f64; 3]" }, { name = "width", ty = "f64" }, { name = "height", ty = "f64" }, { name = "view_target", ty = "[f64; 3]" }, { name = "view_height", ty = "f64" }] -op = "CreateViewport" - -[[family.method]] -name = "viewport_view" -kind = "query" -query = "GetViewportView" -returns = "([f64; 3], f64)" - -[[family.method]] -name = "set_view" -kind = "op" -op = "SetViewportView" -args = [{ name = "view_target", ty = "[f64; 3]" }, { name = "view_height", ty = "f64" }] -returns = "()" - -# ============================================================================ -# MEDIA & MISC (phase 5, read-mostly) — read-only EntityView, no typed create. -# These families report a real `kind` via GetEntity and coarse bounds where -# computable; they have no typed construction/geometric ops in v1. -# ============================================================================ -[[family]] -name = "RasterImage" -acadrust_variant = "RasterImage" -handle = "Entity" -collection = "entities" -description = "Raster image: create_raster_image(file_path, insertion, u, v, size) places an image (host auto-registers the ImageDefinition); bounds (4-corner bracket); delete." - -[[family.constructor]] -name = "create_raster_image" -args = [{ name = "file_path", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "u_vector", ty = "[f64; 3]" }, { name = "v_vector", ty = "[f64; 3]" }, { name = "size", ty = "[f64; 2]" }] -op = "CreateRasterImage" - -[[family]] -name = "Table" -acadrust_variant = "Table" -handle = "Entity" -collection = "entities" -description = "Table: create_table(insertion_point, data[row][col]) builds rows x columns of text cells; non-rectangular grid -> Validation; delete." - -[[family.constructor]] -name = "create_table" -args = [{ name = "insertion_point", ty = "[f64; 3]" }, { name = "data", ty = "&[Vec]" }] -op = "CreateTable" - -# ============================================================================ -# XDATA / XRECORD FAMILY — application-specific extended data roundtrip -# ============================================================================ -[[family]] -name = "XRecord" -acadrust_variant = "XRecord" -handle = "XRecord" -collection = "entities" -description = "Extended record (XRecord): arbitrary DXF group-code/value pairs. Roundtripped verbatim as an XRecordSpec payload." - -[[family.constructor]] -name = "create_xrecord" -args = [{ name = "spec", ty = "XRecordSpec" }] -op = "CreateXRecord" -returns = "XRecord" - -[[family.method]] -name = "payload" -kind = "query" -query = "GetXRecord" -returns = "XRecordSpec" - -[[family.method]] -name = "set_payload" -kind = "op" -op = "SetXRecord" -args = [{ name = "spec", ty = "XRecordSpec" }] -returns = "()" - -# ============================================================================ -# Cross-cutting ops (generic handles + bulk) — documented in build.rs -# ============================================================================ -# - Generic handle methods on EVERY handle: bounds() -> Aabb, transform(placement), -# delete(), id() -> ObjectId. (transform is solids-only in v1.) -# - EntityCollection: get(id) -> Entity, delete(id), transform_many(&ids, pl), -# delete_many(&ids). -# - Bulk ops (CreateMany / TransformMany / DeleteMany): single op, all-or-nothing -# via upfront validation, item-capped (BULK_ITEM_CAP). -# - Read-only batching: query_batch(|q| ...) — one round-trip, no revision bump. -# - OpGroup: best-effort client-side failure cleanup (NOT a transaction). - -# ============================================================================ -# LAYER family (named drawing layers) -# ============================================================================ -[[family]] -name = "Layer" -handle = "Layer" -collection = "layers" -description = "Named drawing layer (color, linetype, lineweight, visibility). Not an entity handle; accessed through Document::layers()." - -[[family.method]] -name = "list" -kind = "query" -query = "ListLayers" -returns = "Vec" - -[[family.method]] -name = "create" -kind = "op" -op = "CreateLayer" -args = [{ name = "info", ty = "LayerInfo" }] -returns = "()" - -[[family.method]] -name = "update" -kind = "op" -op = "UpdateLayer" -args = [{ name = "name", ty = "String" }, { name = "info", ty = "LayerInfo" }] -returns = "()" - -[[family.method]] -name = "delete" -kind = "op" -op = "DeleteLayer" -args = [{ name = "name", ty = "String" }] -returns = "()" - -# ---- Kernel/op mapping (reference for the executor + docs) ---- -[[kernel_map]] -op = "CreateSolid.Cuboid" -kernel = "cadkernel::brep::make::cuboid" -returns = "Option" - -[[kernel_map]] -op = "CreateSolid.Sphere" -kernel = "cadkernel::brep::make::sphere" -returns = "Option" - -[[kernel_map]] -op = "SolidBoolean" -kernel = "cadkernel::brep::combine" -returns = "Result" - -[[kernel_map]] -op = "Transform" -kernel = "cadkernel::brep::transform" -returns = "Option" - -[[kernel_map]] -op = "GetBounds" -kernel = "cadkernel::brep::body_bounds" -returns = "Option" - -[[kernel_map]] -op = "GetVolume" -kernel = "cadkernel::brep::mesh_body + divergence" -returns = "computed" diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs deleted file mode 100644 index 51e4cfafe..000000000 --- a/crates/ocs_doc_api/src/backend.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! The thin host hook. The host implements this trait; -//! the crate's [`crate::executor`] drives it. Both the in-process transport and -//! the host's IPC executor call into it. Logic lives in the versioned crate; the -//! host only provides scene primitives. - -use crate::error::{ApiError, ApiResult}; -use crate::id::ObjectId; -use crate::ops::{Curve2Spec, LayerInfo}; -use crate::query::{Aabb, EntityView}; -use crate::revision::GeometryRevision; - -/// A kernel B-rep solid body, opaque to the wire. On `host` this is -/// `cadkernel::brep::Body`; kernel keys never cross the API (decision #7). -pub type KernelBody = cadkernel::brep::Body; - -/// The per-op host hook. Every method maps to an already-present scene primitive. -/// The executor calls `push_undo` BEFORE applying a write op and `finalize_op` -/// AFTER — recording exactly one undo step + one geometry bump per applied op. -pub trait DocApiBackend { - // ── identity / solid lookup ──────────────────────────────────────────── - /// Resolve a live B-rep body for `id` (solid cache; re-lift from AcisData on - /// miss). `UnknownId` if `id` is not a live solid. NOTE: returns an OWNED body - /// (a clone of the cache entry); use `with_body` for read-only queries. - fn resolve_body(&mut self, id: ObjectId) -> ApiResult; - - /// Borrow the cached body read-only for a query, avoiding the `resolve_body` - /// deep clone. Default: falls back to `resolve_body` (clone). The host overrides - /// this to borrow the `solid_models` cache in place (O(1), no copy). - fn with_body( - &mut self, - id: ObjectId, - f: &mut dyn FnMut(&KernelBody) -> ApiResult, - ) -> ApiResult { - let body = self.resolve_body(id)?; - f(&body) - } - - /// Store a new solid body as a `Solid3D` entity; returns the fresh `ObjectId`. - /// Mirrors host `add_solid_model` (edge wires + `solid_to_sat` + - /// `set_sat_document` + `register_solid_model`). - fn store_solid(&mut self, body: &KernelBody) -> ApiResult; - - /// Replace the body of an existing solid in place (same `ObjectId`). - /// Re-embeds SAT and refreshes the solid cache. - fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()>; - - // ── plain (2D) entities ──────────────────────────────────────────────── - /// Add a 2D curve entity; returns the fresh `ObjectId`. - fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult; - - /// Prepare every entity before recording one undo step and committing the batch. - fn create_many(&mut self, specs: &[crate::ops::EntitySpec]) -> ApiResult>; - - /// Prepare all transformed geometry before committing any entity. - fn transform_many( - &mut self, - ids: &[ObjectId], - placement: &crate::PlacementSpec, - ) -> ApiResult<()>; - - /// Add a block reference (`INSERT`). Validates `block_name` exists; returns - /// the fresh `ObjectId`. `Validation` if the block is unknown. - fn add_insert(&mut self, spec: &crate::ops::InsertSpec) -> ApiResult; - - /// Add a paper-space `VIEWPORT`; returns the fresh `ObjectId`. - fn add_viewport(&mut self, spec: &crate::ops::ViewportSpec) -> ApiResult; - - /// Add a single-line `TEXT` annotation; returns the fresh `ObjectId`. - fn add_text(&mut self, spec: &crate::ops::TextSpec) -> ApiResult; - - /// Add a multi-line `MTEXT` annotation; returns the fresh `ObjectId`. - fn add_mtext(&mut self, spec: &crate::ops::MTextSpec) -> ApiResult; - - /// Set the text content of a Text/MText annotation in place. - fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()>; - - /// The text content of a Text/MText annotation. `Unsupported` for other kinds. - fn text_content(&self, id: ObjectId) -> ApiResult; - - /// Add a `HATCH` (single closed polyline boundary); returns the fresh `ObjectId`. - fn add_hatch(&mut self, spec: &crate::ops::HatchSpec) -> ApiResult; - - /// A hatch's boundary loops (outer + islands) as closed polylines. - fn hatch_boundary(&self, id: ObjectId) -> ApiResult>>; - - /// Add a linear `DIMENSION`; returns the fresh `ObjectId`. - fn add_dimension_linear(&mut self, spec: &crate::ops::DimensionSpec) -> ApiResult; - - /// Add a radial `DIMENSION`; returns the fresh `ObjectId`. - fn add_dimension_radius( - &mut self, - spec: &crate::ops::DimensionRadialSpec, - ) -> ApiResult; - - /// Add a diameter `DIMENSION`; returns the fresh `ObjectId`. - fn add_dimension_diameter( - &mut self, - spec: &crate::ops::DimensionRadialSpec, - ) -> ApiResult; - - /// Add a 3-point angular `DIMENSION`; returns the fresh `ObjectId`. - fn add_dimension_angular( - &mut self, - spec: &crate::ops::DimensionAngularSpec, - ) -> ApiResult; - - /// Add an `ATTDEF` (in-block attribute definition); returns the fresh `ObjectId`. - fn add_attribute_definition( - &mut self, - spec: &crate::ops::AttributeDefinitionSpec, - ) -> ApiResult; - - /// Add a `TABLE` (rows × columns grid); returns the fresh `ObjectId`. - fn add_table(&mut self, spec: &crate::ops::TableSpec) -> ApiResult; - - /// Add a 2-line angular `DIMENSION`; returns the fresh `ObjectId`. - fn add_dimension_angular2ln( - &mut self, - spec: &crate::ops::DimensionAngularSpec, - ) -> ApiResult; - - /// A dimension's measured value (distance for linear/radius, degrees for angular). - fn dimension_measurement(&self, id: ObjectId) -> ApiResult; - - /// Set an attribute `value` for `tag` on an insert (adds if absent). Insert-only. - fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()>; - - /// An insert's attributes as (tag, value) pairs. Insert-only. - fn attributes(&self, id: ObjectId) -> ApiResult>; - - /// The entities inside a block definition (read-only traversal), as views. - fn block_entities(&self, block_name: &str) -> ApiResult>; - - /// Set a viewport's view target + zoom height in place. Viewport-only. - fn set_viewport_view( - &mut self, - id: ObjectId, - view_target: [f64; 3], - view_height: f64, - ) -> ApiResult<()>; - - /// A viewport's view (target WCS + zoom height). Viewport-only. - fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)>; - - /// Add a `RASTER_IMAGE` (host auto-registers the ImageDefinition). - fn add_raster_image(&mut self, spec: &crate::ops::RasterImageSpec) -> ApiResult; - - /// Loft a solid through >= 2 profile curve sets (each already resolved to - /// `geom2d::Curve`s on the XY plane). Returns the fresh `ObjectId`. - fn loft( - &mut self, - sections: &[(cadkernel::space::Plane, Vec)], - ) -> ApiResult; - - /// Attach or replace an XDATA record for `application_name` on `id`. - /// `None` removes any existing record for that application. - fn set_xdata( - &mut self, - id: ObjectId, - application_name: &str, - record: Option<&crate::ops::XDataRecord>, - ) -> ApiResult<()>; - - /// Read the XDATA record for `application_name` on `id` (`None` if absent). - fn xdata( - &self, - id: ObjectId, - application_name: &str, - ) -> ApiResult>; - - /// Create a standalone `XRECORD` object from a spec; returns the fresh `ObjectId`. - fn add_xrecord(&mut self, spec: &crate::ops::XRecordSpec) -> ApiResult; - - /// Replace an existing `XRECORD` object's payload in place. - fn set_xrecord(&mut self, id: ObjectId, spec: &crate::ops::XRecordSpec) -> ApiResult<()>; - - /// Read an existing `XRECORD` object's payload (`None` if `id` is not an XRecord). - fn xrecord(&self, id: ObjectId) -> ApiResult>; - - // ── layer CRUD ───────────────────────────────────────────────────────── - /// Create a new layer. Fails with `Validation` if the name already exists or is reserved. - fn create_layer(&mut self, info: &crate::ops::LayerInfo) -> ApiResult<()>; - - /// Update an existing layer's properties by name. Fails with `Validation` if absent. - fn update_layer(&mut self, name: &str, info: &crate::ops::LayerInfo) -> ApiResult<()>; - - /// Delete a layer by name. Fails with `Validation` if the layer is "0", - /// the current layer, does not exist, or has entities. - fn delete_layer(&mut self, name: &str) -> ApiResult<()>; - - /// Look up a layer by raw name, normalizing it. Default implementation scans - /// `layers()`; backends with O(1) lookup should override. - fn lookup_layer(&self, name: &str) -> ApiResult { - let norm = crate::layer::normalize_layer_name(name); - if norm.is_empty() { - return Err(ApiError::validation("LookupLayer", "empty layer name")); - } - self.layers()? - .into_iter() - .find(|l| l.normalized_name() == norm) - .ok_or_else(|| { - ApiError::validation( - "LookupLayer", - format!("layer '{name}' does not exist"), - ) - }) - } - - /// Move an entity to a different layer. The target layer must exist. - fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()>; - - /// List all layers in the document. - fn layers(&self) -> ApiResult>; - - /// The layer name of an entity. - fn entity_layer(&self, id: ObjectId) -> ApiResult; - - /// Enumerate first-class entities, optionally filtered by kind and/or layer. - /// `include_bounds` controls whether `EntityView::bounds` is populated; skip it - /// for lightweight list views. - fn enumerate_entities( - &self, - kind: Option<&str>, - layer: Option<&str>, - include_bounds: bool, - ) -> ApiResult>; - - /// The WCS location of a Point entity. - fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]>; - - /// The start and end WCS points of a Line entity. - fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])>; - - /// Can `id` be modified in place right now (exists, is the expected family, - /// not on a locked layer)? Read-only pre-check used before mutations. - /// Default: existence + not-locked (backends narrow the family check). - fn can_modify(&self, id: ObjectId) -> ApiResult<()> { - if self.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - - /// Remove any first-level entity by id. Returns `false` if absent; returns - /// `Err` if the entity exists but cannot be removed (e.g. locked layer). - fn remove_entity(&mut self, id: ObjectId) -> ApiResult; - - /// Can `id` be removed right now (exists and is not blocked, e.g. by a locked - /// layer)? Read-only; used by the boolean-erase path to validate BEFORE any - /// mutation so a mid-op failure cannot leave partial state. Default: existence-only. - fn can_remove(&self, id: ObjectId) -> ApiResult<()> { - if self.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - - /// Whether an object (named-object / XRecord / dictionary) exists. Distinct - /// from `entity_exists`, which is for first-class entities only. - fn object_exists(&self, id: ObjectId) -> bool; - - /// A generic, untyped view of any entity (id + kind + coarse bounds). - fn get_entity(&mut self, id: ObjectId) -> ApiResult; - - /// The 2D curve geometry of a profile entity (polyline/closed curve) for - /// sweep ops (`Extrude`/`Revolve`). `UnknownId`/`Unsupported` if not a profile. - fn profile_curves(&self, id: ObjectId) -> ApiResult>; - - fn profile_plane(&self, _id: ObjectId) -> ApiResult { - Ok(cadkernel::space::Plane::XY) - } - - /// Apply a rigid similarity to any entity in place (same `ObjectId`). - fn transform_entity( - &mut self, - id: ObjectId, - placement: &crate::ops::PlacementSpec, - ) -> ApiResult<()>; - - /// Validate that `id` exists and can be transformed, WITHOUT mutating. - /// Used by `TransformMany` to pre-validate all ids before `push_undo` so the - /// apply loop cannot fail part-way (all-or-nothing, the bulk contract). Default: - /// existence-only (backends that restrict transform to a family override). - fn ensure_transformable(&mut self, id: ObjectId) -> ApiResult<()> { - if self.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - - /// Insert a vertex into a polyline at index `at` (polyline family only). - fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()>; - - // ── queries (no undo, no bump; `&mut` because solid resolution may - // populate the kernel body cache on miss) ───────────────────────────── - fn bounds(&mut self, id: ObjectId) -> ApiResult; - fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]>; - fn volume(&mut self, id: ObjectId) -> ApiResult; - fn entity_exists(&self, id: ObjectId) -> bool; - fn revision(&self) -> GeometryRevision; - - // ── per-op boundary ──────────────────────────────────────────────────── - /// Called BEFORE applying a write op (records the before-state undo snapshot). - fn push_undo(&mut self, label: &str); - /// Called AFTER a successful write op (one DeltaSnapshot + one geometry bump - /// + one publish). NOT called on failure (a failed op records nothing). - fn finalize_op(&mut self); - /// Discard an undo capture after preparation or validation failed. - fn cancel_op(&mut self) {} -} diff --git a/crates/ocs_doc_api/src/convert.rs b/crates/ocs_doc_api/src/convert.rs deleted file mode 100644 index db1f0a22f..000000000 --- a/crates/ocs_doc_api/src/convert.rs +++ /dev/null @@ -1,271 +0,0 @@ -//! Entity conversions: map the crate's plain-data DTOs to acadrust -//! `EntityType` and back. Host feature only (uses the real acadrust types). This -//! module is host-side-capable but host-independent — it operates purely on -//! acadrust entities and crate DTOs, so any host can reuse it. - -use acadrust::entities::{ - Arc, Circle, Ellipse, Line, LwPolyline, LwVertex, Point, Ray, Spline, XLine, -}; -use acadrust::types::{Vector2, Vector3}; -use acadrust::EntityType; - -use crate::{Aabb, ApiError, ApiResult, Curve2Spec, GeometryErrorKind, ObjectId}; - -/// Convert a profile entity (Line/Circle/Arc/LwPolyline) to `geom2d::Curve`s for -/// sweep ops (`Extrude`/`Revolve`). Z is dropped (profiles lie on the XY plane). -pub fn entity_to_profile_curves(entity: &EntityType) -> ApiResult> { - use cadkernel::geom2d::{Arc as KArc, Circle as KCircle, Curve as KCurve, Line as KLine}; - let curves = match entity { - EntityType::Line(l) => vec![KCurve::Line(KLine { - start: [l.start.x, l.start.y], - end: [l.end.x, l.end.y], - })], - EntityType::Circle(c) => vec![KCurve::Circle(KCircle { - centre: [c.center.x, c.center.y], - radius: c.radius, - })], - EntityType::Arc(a) => vec![KCurve::Arc(KArc { - centre: [a.center.x, a.center.y], - radius: a.radius, - start_angle: a.start_angle, - end_angle: a.end_angle, - })], - EntityType::LwPolyline(pl) => { - if pl.vertices.len() < 2 { - return Err(ApiError::validation( - "profile", - "polyline profile needs at least two vertices", - )); - } - KCurve::Polyline(cadkernel::geom2d::Polyline { - vertices: pl - .vertices - .iter() - .map(|v| cadkernel::geom2d::PolylineVertex { - position: [v.location.x, v.location.y], - bulge: v.bulge, - }) - .collect(), - closed: pl.is_closed, - }) - .segments() - } - _ => { - return Err(ApiError::Unsupported( - "profile conversion is supported for Line/Circle/Arc/LwPolyline only".into(), - )) - } - }; - Ok(curves) -} - -/// Apply a rigid similarity (rotation+translation+uniform scale) to a non-solid -/// entity in place. Supports the common 2D families; `Solid3D` goes through the -/// kernel path instead. Returns the transformed entity (same handle) for the -/// caller to `update_entity`. -pub fn transform_entity_geometry( - entity: &EntityType, - p: &crate::PlacementSpec, -) -> ApiResult { - crate::geom::validate_placement(p)?; - let matrix = acadrust::types::Matrix4 { - m: [ - [p.x_axis[0], p.y_axis[0], p.z_axis[0], p.origin[0]], - [p.x_axis[1], p.y_axis[1], p.z_axis[1], p.origin[1]], - [p.x_axis[2], p.y_axis[2], p.z_axis[2], p.origin[2]], - [0.0, 0.0, 0.0, 1.0], - ], - }; - let mut moved = entity.clone(); - moved.apply_transform(&acadrust::types::Transform::from_matrix(matrix)); - Ok(moved) -} - -fn v3(p: [f64; 3]) -> Vector3 { - Vector3::new(p[0], p[1], p[2]) -} - -/// Convert a polyline bulge segment (start → end, bulge = tan(θ/4)) to a -/// `geom2d::Curve::Arc`. Positive bulge = counter-clockwise arc; negative = clockwise. -pub fn bulge_arc_segment( - start: [f64; 2], - end: [f64; 2], - bulge: f64, -) -> ApiResult { - use cadkernel::geom2d::{Arc as KArc, Curve as KCurve}; - let arc = cadkernel::geom2d::BulgeArc::from_bulge(start, end, bulge) - .ok_or_else(|| ApiError::validation("profile", "invalid bulge segment"))?; - // The kernel's Arc is counter-clockwise; reverse clockwise endpoints. - let (start_angle, end_angle) = if arc.sweep < 0.0 { - (arc.end_angle, arc.start_angle) - } else { - (arc.start_angle, arc.end_angle) - }; - Ok(KCurve::Arc(KArc { - centre: arc.center, - radius: arc.radius, - start_angle, - end_angle, - })) -} - -/// Build an acadrust entity for a 2D curve construction spec. -pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { - crate::validation::curve(spec)?; - let entity = match spec { - Curve2Spec::Line { start, end, .. } => { - let mut e = Line::new(); - e.start = v3(*start); - e.end = v3(*end); - EntityType::Line(e) - } - Curve2Spec::Circle { centre, radius, .. } => { - let mut e = Circle::new(); - e.center = v3(*centre); - e.radius = *radius; - EntityType::Circle(e) - } - Curve2Spec::Polyline { points, closed, .. } => { - let mut e = LwPolyline::new(); - e.vertices = points - .iter() - .map(|p| LwVertex { - location: Vector2::new(p[0], p[1]), - bulge: 0.0, - start_width: 0.0, - end_width: 0.0, - vertex_id: 0, - }) - .collect(); - e.elevation = points.first().map_or(0.0, |p| p[2]); - if points.iter().any(|p| p[2] != e.elevation) { - return Err(ApiError::validation( - "CreateCurve", - "polyline points must share an elevation", - )); - } - e.is_closed = *closed; - EntityType::LwPolyline(e) - } - Curve2Spec::Point { position, .. } => { - let mut e = Point::new(); - e.location = v3(*position); - EntityType::Point(e) - } - Curve2Spec::Arc { - centre, - radius, - start_angle, - end_angle, - .. - } => { - let mut e = Arc::new(); - e.center = v3(*centre); - e.radius = *radius; - e.start_angle = *start_angle; - e.end_angle = *end_angle; - EntityType::Arc(e) - } - Curve2Spec::Ellipse { - centre, - major_axis, - ratio, - start, - end, - .. - } => { - // Validate the minor/major ratio so the (major-axis) bounds never - // under-bound: ratio must be in (0, 1]. - if !(*ratio > 0.0 && *ratio <= 1.0) { - return Err(ApiError::validation( - "CreateCurve", - format!("ellipse minor_axis_ratio must be in (0, 1], got {ratio}"), - )); - } - let mut e = Ellipse::new(); - e.center = v3(*centre); - e.major_axis = v3(*major_axis); - e.minor_axis_ratio = *ratio; - e.start_parameter = *start; - e.end_parameter = *end; - EntityType::Ellipse(e) - } - Curve2Spec::Spline { - degree, - control_points, - knots, - weights, - .. - } => { - let mut e = Spline::new(); - e.degree = *degree; - e.control_points = control_points.iter().map(|p| v3(*p)).collect(); - e.knots = knots.clone(); - e.weights = weights.clone(); - EntityType::Spline(e) - } - Curve2Spec::Ray { origin, direction, .. } => { - EntityType::Ray(Ray::new(v3(*origin), v3(*direction))) - } - Curve2Spec::XLine { origin, direction, .. } => { - EntityType::XLine(XLine::new(v3(*origin), v3(*direction))) - } - }; - Ok(entity) -} - -/// The acadrust `EntityType` variant name (for `EntityView::kind` / downcasts). -pub fn entity_kind_name(entity: &EntityType) -> &'static str { - match entity { - EntityType::Solid3D(_) => "Solid3D", - EntityType::Line(_) => "Line", - EntityType::Circle(_) => "Circle", - EntityType::LwPolyline(_) => "LwPolyline", - EntityType::Polyline(_) => "Polyline", - EntityType::Point(_) => "Point", - EntityType::Arc(_) => "Arc", - EntityType::Ellipse(_) => "Ellipse", - EntityType::Spline(_) => "Spline", - EntityType::Ray(_) => "Ray", - EntityType::XLine(_) => "XLine", - EntityType::Insert(_) => "Insert", - EntityType::Viewport(_) => "Viewport", - EntityType::Text(_) => "Text", - EntityType::MText(_) => "MText", - EntityType::AttributeDefinition(_) => "AttributeDefinition", - EntityType::AttributeEntity(_) => "AttributeEntity", - // Media & misc (phase 5, read-mostly): named so GetEntity reports a real kind. - EntityType::RasterImage(_) => "RasterImage", - EntityType::Table(_) => "Table", - EntityType::Leader(_) => "Leader", - EntityType::MultiLeader(_) => "MultiLeader", - EntityType::MLine(_) => "MLine", - EntityType::Mesh(_) => "Mesh", - EntityType::Helix(_) => "Helix", - EntityType::Region(_) => "Region", - EntityType::Body(_) => "Body", - EntityType::Surface(_) => "Surface", - EntityType::Face3D(_) => "Face3D", - EntityType::Dimension(_) => "Dimension", - EntityType::Hatch(_) => "Hatch", - _ => "Other", - } -} - -/// Existing entity bounds; unbounded or empty geometry returns an error. -pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { - let entity = entity.ok_or(ApiError::UnknownId(id))?; - if matches!(entity, EntityType::Ray(_) | EntityType::XLine(_)) { - return Err(ApiError::Unsupported("unbounded entity".into())); - } - let bounds = entity.as_entity().bounding_box(); - let min = [bounds.min.x, bounds.min.y, bounds.min.z]; - let max = [bounds.max.x, bounds.max.y, bounds.max.z]; - if min.iter().chain(&max).any(|v| !v.is_finite()) { - return Err(ApiError::geometry( - GeometryErrorKind::Empty, - "entity has no finite bounds", - )); - } - Ok(Aabb { min, max }) -} diff --git a/crates/ocs_doc_api/src/envelope.rs b/crates/ocs_doc_api/src/envelope.rs deleted file mode 100644 index 0f847740f..000000000 --- a/crates/ocs_doc_api/src/envelope.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! The wire envelope. One request = ONE write op OR a query batch -//! (read-only batching is safe: no mutation, no undo, no rollback). - -use serde::{Deserialize, Serialize}; - -use crate::gen::{Operation, Query}; -use crate::id::ObjectId; -use crate::query::QueryResult; -use crate::revision::GeometryRevision; -use crate::ENVELOPE_VERSION; - -/// The single request unit crossing the transport. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DocApiEnvelope { - /// Crate protocol version (`ENVELOPE_VERSION`). Bridges must check this. - pub version: u16, - pub body: EnvelopeBody, -} - -impl DocApiEnvelope { - pub fn validate_version(&self) -> crate::ApiResult<()> { - if self.version != ENVELOPE_VERSION { - return Err(crate::ApiError::Unsupported(format!( - "envelope version {}", - self.version - ))); - } - Ok(()) - } - - pub fn op(operation: Operation) -> Self { - Self { - version: ENVELOPE_VERSION, - body: EnvelopeBody::Op(operation), - } - } - pub fn queries(queries: Vec) -> Self { - Self { - version: ENVELOPE_VERSION, - body: EnvelopeBody::Queries(queries), - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum EnvelopeBody { - /// Exactly ONE write op (write batching removed, plan §13). Atomic by itself. - Op(Operation), - /// One-or-more read-only queries answered together (safe: no mutation/undo). - Queries(Vec), -} - -/// The outcome of an applied write op. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum OpOutcome { - /// A new entity was created; carries its fresh `ObjectId`. - NewId(ObjectId), - /// An existing entity was updated in place (same `ObjectId` preserved). - Updated(ObjectId), - /// Entities were deleted; carries the deleted ids (one for `Delete`, N for `DeleteMany`). - Deleted(Vec), - /// Many entities were created (bulk `CreateMany`); carries all fresh ids in order. - NewIds(Vec), -} - -impl OpOutcome { - /// The single fresh id, for ops that create exactly one entity. - pub fn new_id(&self) -> Option { - match self { - OpOutcome::NewId(id) => Some(*id), - _ => None, - } - } - /// All fresh ids, for bulk creates. - pub fn new_ids(&self) -> &[ObjectId] { - match self { - OpOutcome::NewIds(ids) => ids, - OpOutcome::NewId(id) => std::slice::from_ref(id), - _ => &[], - } - } -} - -/// The per-request result (and the query-batch answer carrier). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Receipt { - /// Present for `EnvelopeBody::Op`; absent for pure queries. - pub outcome: Option, - /// One `QueryResult` per `Query` in a `Queries` batch (in order). - pub query_results: Vec, - /// The geometry revision after this request (== before, for pure queries). - pub new_revision: GeometryRevision, -} diff --git a/crates/ocs_doc_api/src/error.rs b/crates/ocs_doc_api/src/error.rs deleted file mode 100644 index 783ec1434..000000000 --- a/crates/ocs_doc_api/src/error.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! Structured error model. Serializable so IPC returns the -//! same error the in-process executor produced. - -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use crate::id::ObjectId; - -/// Which kind of kernel failure occurred (a mapped `cadkernel` `Snag`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum GeometryErrorKind { - /// The operation produced an empty / degenerate body. - Empty, - /// Boolean refused (coincident geometry, unsupported cut, …). - BooleanFailed, - /// Invalid input to a kernel constructor (e.g. non-positive radius). - InvalidInput, - /// ACIS lift/parse/lower failed. - Acis, - /// Any other kernel error (message carries detail). - Other, -} - -/// The single error type for the whole API. `commit`/ops return -/// `Result<_, ApiError>`; on a failed op nothing is applied and no undo step is -/// recorded (per-op atomicity). -#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum ApiError { - /// Input failed validation before any mutation (names the op + reason). - #[error("validation failed on {op}: {reason}")] - Validation { op: String, reason: String }, - - /// The geometry kernel reported a failure (a mapped `Snag`). - #[error("geometry kernel: {kind:?}: {msg}")] - Geometry { - kind: GeometryErrorKind, - msg: String, - }, - - /// An `ObjectId` does not resolve to a live entity (stale/deleted/never existed). - #[error("unknown ObjectId {0:?}")] - UnknownId(ObjectId), - - /// The requested capability is not supported (e.g. over-cap bulk op, unimplemented family). - #[error("unsupported capability: {0}")] - Unsupported(String), - - /// The transport (IPC / channel) failed — disconnected, oversized, timeout. - #[error("transport: {0}")] - Transport(String), -} - -pub type ApiResult = Result; - -impl ApiError { - pub fn validation(op: &'static str, reason: impl Into) -> Self { - ApiError::Validation { - op: op.to_string(), - reason: reason.into(), - } - } - pub fn geometry(kind: GeometryErrorKind, msg: impl Into) -> Self { - ApiError::Geometry { - kind, - msg: msg.into(), - } - } -} diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs deleted file mode 100644 index 423fc0658..000000000 --- a/crates/ocs_doc_api/src/executor.rs +++ /dev/null @@ -1,616 +0,0 @@ -//! The op executor: crate-owned logic mapping each -//! `Operation`/`Query` to `DocApiBackend` calls. Both the in-process transport -//! and the host's IPC executor use this — one implementation, versioned in the -//! crate. Per-op atomicity: a write op validates its inputs and computes its -//! geometry BEFORE calling `push_undo`, mutates, then `finalize_op` once. - -use crate::backend::DocApiBackend; -use crate::envelope::{OpOutcome, Receipt}; -use crate::error::{ApiError, ApiResult}; -use crate::gen::{Operation, Query}; -use crate::id::ObjectId; -use crate::ops::{BoolOp, EntitySpec, BULK_ITEM_CAP}; -use crate::query::QueryResult; - -/// Apply ONE write op atomically. On success: one undo step and an advanced revision. -/// On failure: nothing applied, no undo, no bump (the op validates before mutating). -pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { - let result = apply_op_inner(b, op); - if result.is_err() { - b.cancel_op(); - } - result -} - -fn apply_op_inner(b: &mut B, op: Operation) -> ApiResult { - let name = op.op_name(); - crate::validation::operation(&op)?; - match &op { - Operation::TransformMany { ids, .. } | Operation::DeleteMany(ids) => { - let unique: std::collections::HashSet<_> = ids.iter().collect(); - if ids.is_empty() || unique.len() != ids.len() { - return Err(ApiError::validation( - name, - "batch ids must be nonempty and unique", - )); - } - } - Operation::SetXDataMany(records) => { - if records.is_empty() { - return Err(ApiError::validation( - name, - "SetXDataMany records must be nonempty", - )); - } - if records.len() > BULK_ITEM_CAP { - return Err(over_cap(name, records.len())); - } - let unique: std::collections::HashSet<_> = records.iter().map(|(id, _, _)| id).collect(); - if unique.len() != records.len() { - return Err(ApiError::validation( - name, - "SetXDataMany records must have unique entity ids", - )); - } - } - _ => {} - } - if let Operation::Transform { placement, .. } | Operation::TransformMany { placement, .. } = &op - { - crate::geom::validate_placement(placement)?; - } - let outcome = match &op { - Operation::CreateSolid(prim) => { - let body = crate::geom::make_solid(prim)?; - b.push_undo(name); - let id = b.store_solid(&body)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateCurve(spec) => { - b.push_undo(name); - let id = b.add_curve(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::Extrude { profile, direction } => { - let curves = profile_curves(b, *profile, name)?; - let body = crate::geom::extrude(b.profile_plane(*profile)?, &curves, *direction)?; - b.push_undo(name); - let id = b.store_solid(&body)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::Revolve { - profile, - axis, - angle, - } => { - let curves = profile_curves(b, *profile, name)?; - let (pivot, axis_dir) = *axis; - let body = - crate::geom::revolve(b.profile_plane(*profile)?, &curves, pivot, axis_dir, *angle)?; - b.push_undo(name); - let id = b.store_solid(&body)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::Transform { id, placement } => { - require_exists(b, *id, name)?; - b.push_undo(name); - b.transform_entity(*id, placement)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::SolidBoolean { - op: bop, - a, - b: bid, - erase_sources, - } => { - // Resolve + combine BEFORE push_undo: pure computation, no mutation. - let ba = b.resolve_body(*a).map_err(|_| ApiError::UnknownId(*a))?; - let bb = b - .resolve_body(*bid) - .map_err(|_| ApiError::UnknownId(*bid))?; - let result = crate::geom::boolean(&ba, &bb, *bop)?; - // Validate the erase BEFORE mutating: if `b` cannot be removed (e.g. - // locked layer), fail now rather than after `update_solid(a)` has - // already committed a partial mutation. - if *erase_sources { - if a == bid { - return Err(ApiError::validation(name, "boolean source ids must differ")); - } - b.can_modify(*a)?; - b.can_remove(*bid)?; - } - b.push_undo(name); - // `erase_sources`: the result lives at `a` (updated in place), `b` erased. - // Otherwise store a fresh solid and leave both inputs untouched. - let out_id = if *erase_sources { - b.update_solid(*a, &result)?; - b.remove_entity(*bid)?; - *a - } else { - b.store_solid(&result)? - }; - b.finalize_op(); - OpOutcome::NewId(out_id) - } - Operation::AddVertex { id, at, point } => { - require_exists(b, *id, name)?; - b.push_undo(name); - b.add_vertex(*id, *at, *point)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::Delete { id } => { - // can_remove validates existence AND removability (locked layer) before - // any mutation, so a locked entity errors cleanly instead of a no-op - // reported as Deleted. - b.can_remove(*id).map_err(|e| match e { - ApiError::UnknownId(_) => { - ApiError::validation(name, format!("unknown ObjectId {id:?}")) - } - other => other, - })?; - b.push_undo(name); - b.remove_entity(*id)?; - b.finalize_op(); - OpOutcome::Deleted(vec![*id]) - } - Operation::CreateMany(specs) => { - if specs.len() > BULK_ITEM_CAP { - return Err(over_cap(name, specs.len())); - } - if specs.is_empty() { - return Err(ApiError::validation(name, "batch must not be empty")); - } - for spec in specs { - if let EntitySpec::Curve(curve) = spec { - crate::validation::curve(curve)?; - } - } - let ids = b.create_many(specs)?; - OpOutcome::NewIds(ids) - } - Operation::TransformMany { ids, placement } => { - if ids.len() > BULK_ITEM_CAP { - return Err(over_cap(name, ids.len())); - } - // Pre-validate EVERYTHING fallible before push_undo (all-or-nothing, - // the bulk contract): every id must exist AND be transformable, so the apply - // loop below cannot fail part-way. `transform_entity` after this is - // infallible-by-construction for the validated ids. - for (i, id) in ids.iter().enumerate() { - b.ensure_transformable(*id).map_err(|e| match e { - ApiError::UnknownId(_) => stale_index(name, i, *id), - other => match other { - ApiError::Unsupported(msg) => ApiError::Validation { - op: name.to_string(), - reason: format!("index {i}: {msg}"), - }, - o => o, - }, - })?; - } - b.transform_many(ids, placement)?; - OpOutcome::Updated(*ids.first().unwrap_or(&ObjectId::NULL)) - } - Operation::DeleteMany(ids) => { - if ids.len() > BULK_ITEM_CAP { - return Err(over_cap(name, ids.len())); - } - // Pre-validate existence AND removability (locked layer) for every id - // before push_undo, so the apply loop cannot fail part-way. - for (i, id) in ids.iter().enumerate() { - b.can_remove(*id).map_err(|e| match e { - ApiError::UnknownId(_) => stale_index(name, i, *id), - ApiError::Unsupported(msg) => ApiError::Validation { - op: name.to_string(), - reason: format!("index {i}: {msg}"), - }, - other => other, - })?; - } - b.push_undo(name); - for id in ids { - b.remove_entity(*id)?; - } - b.finalize_op(); - OpOutcome::Deleted(ids.clone()) - } - Operation::CreateInsert(spec) => { - // Validate the block name exists before committing (no rollback needed). - b.push_undo(name); - let id = b.add_insert(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateViewport(spec) => { - b.push_undo(name); - let id = b.add_viewport(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateText(spec) => { - b.push_undo(name); - let id = b.add_text(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateMText(spec) => { - b.push_undo(name); - let id = b.add_mtext(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::SetTextContent { id, value } => { - b.can_modify(*id).map_err(|e| match e { - ApiError::UnknownId(_) => { - ApiError::validation(name, format!("unknown ObjectId {id:?}")) - } - other => other, - })?; - b.push_undo(name); - b.set_text_content(*id, value)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::CreateHatch(spec) => { - // Validate the boundary (>= 3 points) before committing. - if spec.boundary.len() < 3 { - return Err(ApiError::validation( - name, - "hatch boundary needs >= 3 points", - )); - } - b.push_undo(name); - let id = b.add_hatch(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionLinear(spec) => { - b.push_undo(name); - let id = b.add_dimension_linear(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::SetAttribute { id, tag, value } => { - b.can_modify(*id).map_err(|e| match e { - ApiError::UnknownId(_) => { - ApiError::validation(name, format!("unknown ObjectId {id:?}")) - } - other => other, - })?; - b.push_undo(name); - b.set_attribute(*id, tag, value)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::SetViewportView { - id, - view_target, - view_height, - } => { - b.can_modify(*id).map_err(|e| match e { - ApiError::UnknownId(_) => { - ApiError::validation(name, format!("unknown ObjectId {id:?}")) - } - other => other, - })?; - b.push_undo(name); - b.set_viewport_view(*id, *view_target, *view_height)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::CreateRasterImage(spec) => { - b.push_undo(name); - let id = b.add_raster_image(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::Loft { profiles } => { - // Bound the section count like the bulk ops (one envelope can carry many - // ids; an uncapped loft would be an unbounded kernel call on the UI thread). - if profiles.len() > BULK_ITEM_CAP { - return Err(over_cap(name, profiles.len())); - } - // Resolve every profile to its curve set BEFORE push_undo (all-or-nothing). - if profiles.len() < 2 { - return Err(ApiError::validation(name, "loft needs >= 2 profiles")); - } - let mut sections = Vec::with_capacity(profiles.len()); - for (i, p) in profiles.iter().enumerate() { - let curves = profile_curves(b, *p, name).map_err(|e| at_index(name, i, e))?; - sections.push((b.profile_plane(*p)?, curves)); - } - b.push_undo(name); - let id = b.loft(§ions)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionRadius(spec) => { - b.push_undo(name); - let id = b.add_dimension_radius(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionDiameter(spec) => { - b.push_undo(name); - let id = b.add_dimension_diameter(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionAngular(spec) => { - b.push_undo(name); - let id = b.add_dimension_angular(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateAttributeDefinition(spec) => { - b.push_undo(name); - let id = b.add_attribute_definition(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateTable(spec) => { - // Validate a non-empty, rectangular grid before committing. - let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); - if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) { - return Err(ApiError::validation( - name, - "table needs a non-empty rectangular grid", - )); - } - b.push_undo(name); - let id = b.add_table(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionAngular2Ln(spec) => { - b.push_undo(name); - let id = b.add_dimension_angular2ln(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateLayer(info) => { - b.push_undo(name); - b.create_layer(info)?; - b.finalize_op(); - OpOutcome::Updated(ObjectId::NULL) - } - Operation::UpdateLayer { name, info } => { - b.push_undo(name); - b.update_layer(name, info)?; - b.finalize_op(); - OpOutcome::Updated(ObjectId::NULL) - } - Operation::DeleteLayer { name } => { - b.push_undo(name); - b.delete_layer(name)?; - b.finalize_op(); - OpOutcome::Updated(ObjectId::NULL) - } - Operation::SetEntityLayer { id, layer } => { - require_exists(b, *id, name)?; - b.push_undo(name); - b.set_entity_layer(*id, layer)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::SetXData { - id, - application_name, - record, - } => { - require_exists(b, *id, name)?; - b.push_undo(name); - b.set_xdata(*id, application_name, record.as_ref())?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::SetXDataMany(records) => { - // A later locked entity must not leave earlier records changed. - for (i, (id, _, _)) in records.iter().enumerate() { - b.can_modify(*id).map_err(|e| match e { - ApiError::UnknownId(_) => stale_index(name, i, *id), - ApiError::Unsupported(reason) => { - ApiError::validation(name, format!("index {i}: {reason}")) - } - other => other, - })?; - } - b.push_undo(name); - for (id, application_name, record) in records { - b.set_xdata(*id, application_name, record.as_ref())?; - } - b.finalize_op(); - OpOutcome::Updated(ObjectId::NULL) - } - Operation::CreateXRecord(spec) => { - b.push_undo(name); - let id = b.add_xrecord(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::SetXRecord { id, spec } => { - require_object_exists(b, *id, name)?; - b.push_undo(name); - b.set_xrecord(*id, spec)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - }; - Ok(Receipt { - outcome: Some(outcome), - query_results: Vec::new(), - new_revision: b.revision(), - }) -} - -/// Apply a batch of read-only queries (no undo, no bump; `&mut` because solid -/// resolution may populate the kernel body cache on miss). Bounded like the bulk -/// ops: a batch over `BULK_ITEM_CAP` is rejected before any work (the 64 MiB frame -/// cap bounds message size, not count). -pub fn apply_queries(b: &mut B, queries: Vec) -> ApiResult { - if queries.len() > BULK_ITEM_CAP { - return Err(ApiError::validation( - "Queries", - format!( - "query batch over cap: {} > {}", - queries.len(), - BULK_ITEM_CAP - ), - )); - } - let mut results = Vec::with_capacity(queries.len()); - for q in &queries { - let qname = q.query_name(); - let r = match q { - Query::GetEntity { id } => { - QueryResult::Entity(b.get_entity(*id).map_err(|e| label(qname, e))?) - } - Query::GetBounds { id } => { - QueryResult::Bounds(b.bounds(*id).map_err(|e| label(qname, e))?) - } - Query::GetCentroid { id } => { - QueryResult::Centroid(b.centroid(*id).map_err(|e| label(qname, e))?) - } - Query::GetVolume { id } => { - QueryResult::Volume(b.volume(*id).map_err(|e| label(qname, e))?) - } - Query::GetIntersects { a, b: bid } => { - let ba = b.bounds(*a).map_err(|e| label(qname, e))?; - let bb = b.bounds(*bid).map_err(|e| label(qname, e))?; - QueryResult::Intersects(ba.overlaps(&bb)) - } - Query::GetGeometryRevision => QueryResult::Revision(b.revision()), - Query::GetTextContent { id } => { - QueryResult::TextContent(b.text_content(*id).map_err(|e| label(qname, e))?) - } - Query::GetHatchBoundary { id } => { - QueryResult::HatchBoundary(b.hatch_boundary(*id).map_err(|e| label(qname, e))?) - } - Query::GetDimensionMeasurement { id } => QueryResult::DimensionMeasurement( - b.dimension_measurement(*id).map_err(|e| label(qname, e))?, - ), - Query::GetAttributes { id } => { - QueryResult::Attributes(b.attributes(*id).map_err(|e| label(qname, e))?) - } - Query::GetBlockEntities { block_name } => QueryResult::BlockEntities( - b.block_entities(block_name).map_err(|e| label(qname, e))?, - ), - Query::GetViewportView { id } => { - let (target, height) = b.viewport_view(*id).map_err(|e| label(qname, e))?; - QueryResult::ViewportView { target, height } - } - Query::GetXData { - id, - application_name, - } => QueryResult::XData( - b.xdata(*id, application_name) - .map_err(|e| label(qname, e))? - .clone(), - ), - Query::GetXRecord { id } => { - QueryResult::XRecord(b.xrecord(*id).map_err(|e| label(qname, e))?.ok_or_else( - || ApiError::validation(qname, format!("ObjectId {id:?} is not an XRecord")), - )?) - } - Query::ListLayers => QueryResult::Layers(b.layers().map_err(|e| label(qname, e))?), - Query::GetEntityLayer { id } => { - QueryResult::EntityLayer(b.entity_layer(*id).map_err(|e| label(qname, e))?) - } - Query::EnumerateEntities { - kind, - layer, - include_bounds, - } => QueryResult::Entities( - b.enumerate_entities(kind.as_deref(), layer.as_deref(), *include_bounds) - .map_err(|e| label(qname, e))?, - ), - Query::GetPointPosition { id } => QueryResult::PointPosition( - b.point_position(*id).map_err(|e| label(qname, e))?, - ), - Query::GetLineGeometry { id } => { - let (start, end) = b.line_geometry(*id).map_err(|e| label(qname, e))?; - QueryResult::LineGeometry((start, end)) - } - }; - results.push(r); - } - Ok(Receipt { - outcome: None, - query_results: results, - new_revision: b.revision(), - }) -} - -/// Attach the query name to an error for better diagnostics (uses `query_name`). -fn label(query: &'static str, e: ApiError) -> ApiError { - match e { - ApiError::Validation { reason, .. } => ApiError::validation(query, reason), - ApiError::UnknownId(id) => ApiError::validation(query, format!("unknown ObjectId {id:?}")), - other => other, - } -} - -fn profile_curves( - b: &B, - profile: ObjectId, - op: &'static str, -) -> ApiResult> { - if !b.entity_exists(profile) { - return Err(ApiError::validation( - op, - format!("unknown ObjectId {profile:?}"), - )); - } - b.profile_curves(profile) -} - -/// Validate that an entity exists before performing an operation. -fn require_exists(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> { - if b.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::validation(op, format!("unknown ObjectId {id:?}"))) - } -} - -/// Validate that a named object exists before performing an operation. -fn require_object_exists(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> { - if b.object_exists(id) { - Ok(()) - } else { - Err(ApiError::validation(op, format!("unknown ObjectId {id:?}"))) - } -} - -fn over_cap(op: &'static str, n: usize) -> ApiError { - ApiError::validation(op, format!("bulk op over cap: {n} > {BULK_ITEM_CAP}")) -} - -fn stale_index(op: &'static str, i: usize, id: ObjectId) -> ApiError { - ApiError::validation(op, format!("stale ObjectId at index {i}: {id:?}")) -} - -fn at_index(op: &'static str, i: usize, e: ApiError) -> ApiError { - match e { - ApiError::Validation { reason, .. } => { - ApiError::validation(op, format!("index {i}: {reason}")) - } - other => other, - } -} - -/// Boolean op kind → kernel `Operation` (host feature only). -#[cfg(feature = "host")] -pub(crate) fn kernel_bool_op(op: BoolOp) -> cadkernel::brep::Operation { - match op { - BoolOp::Union => cadkernel::brep::Operation::Union, - BoolOp::Intersection => cadkernel::brep::Operation::Intersection, - BoolOp::Difference => cadkernel::brep::Operation::Difference, - } -} diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs deleted file mode 100644 index ef6f2b13d..000000000 --- a/crates/ocs_doc_api/src/facade.rs +++ /dev/null @@ -1,1413 +0,0 @@ -//! The object-centric facade. -//! -//! Hierarchy: [`DocApi`] (root: session + transport) → [`Document`] → -//! collections ([`SolidCollection`], [`CurveCollection`], [`EntityCollection`]) -//! → typed handles ([`Solid`], [`Line`], [`Circle`], [`Polyline`], [`Point`], -//! [`Entity`]) carrying methods. Every handle = `{ ObjectId, Arc }` — -//! `Clone`, `Send`, `Sync`; it never holds kernel geometry. Per-op autocommit: -//! each method is ONE atomic host call = one undo step (no transactions). - -use std::sync::Arc; - -use crate::envelope::{DocApiEnvelope, Receipt}; -use crate::error::{ApiError, ApiResult}; -use crate::gen::{Operation, Query}; -use crate::id::ObjectId; -use crate::ops::{BoolOp, Curve2Spec, PlacementSpec, SolidPrimitive, XDataRecord, XRecordSpec}; -use crate::query::{Aabb, EntityView, QueryResult}; -use crate::revision::GeometryRevision; -use crate::transport::Transport; - -/// Shared session: the transport. Held by every handle. The transport already -/// carries the tab binding (IPC) or the backend (in-process), so the session -/// stores no per-tab field. -#[derive(Clone)] -pub struct Session { - transport: Arc, - valid_tab: bool, -} - -impl Session { - fn validate_tab(&self) -> ApiResult<()> { - if !self.valid_tab { - return Err(ApiError::validation( - "document", - "transport is bound to another tab", - )); - } - Ok(()) - } - fn same_transport(&self, other: &Session) -> ApiResult<()> { - self.validate_tab()?; - other.validate_tab()?; - if !Arc::ptr_eq(&self.transport, &other.transport) { - return Err(ApiError::validation( - "document", - "handles belong to different sessions", - )); - } - Ok(()) - } - fn apply_op(&self, op: Operation) -> ApiResult { - self.validate_tab()?; - self.transport.apply(DocApiEnvelope::op(op)) - } - fn apply_queries(&self, queries: Vec) -> ApiResult { - self.validate_tab()?; - self.transport.apply(DocApiEnvelope::queries(queries)) - } - fn one_query(&self, q: Query) -> ApiResult { - let mut r = self.apply_queries(vec![q])?; - let result = r.query_results.drain(..).next(); - result.ok_or_else(|| ApiError::Transport("empty query result".into())) - } -} - -/// The API root: owns the session (transport + active tab). Entry point. -#[derive(Clone)] -pub struct DocApi { - transport: Arc, - active_tab: u64, -} - -impl DocApi { - /// Connect over an existing transport (IPC or in-process) bound to `active_tab`. - pub fn connect(transport: Arc, active_tab: u64) -> Self { - Self { - transport, - active_tab, - } - } - /// Connect in-process over a backend (host feature). - #[cfg(feature = "host")] - pub fn in_process( - backend: B, - active_tab: u64, - ) -> Self { - Self::connect( - Arc::new(crate::transport::InProcess::new(backend)), - active_tab, - ) - } - - /// The active tab id this root is bound to. - pub fn active_tab(&self) -> u64 { - self.active_tab - } - - /// Liveness probe of the host/transport. - pub fn alive(&self) -> bool { - self.transport.alive() - } - - /// Bind a document to the transport's tab. Requests for another tab fail. - pub fn document(&self, tab: u64) -> Document { - Document { - session: Session { - transport: Arc::clone(&self.transport), - valid_tab: tab == self.active_tab, - }, - } - } -} - -/// One open document tab: factory + lookup + queries + read-guards. -#[derive(Clone)] -pub struct Document { - session: Session, -} - -impl Document { - pub fn solids(&self) -> SolidCollection { - SolidCollection { - session: self.session.clone(), - } - } - pub fn curves(&self) -> CurveCollection { - CurveCollection { - session: self.session.clone(), - } - } - pub fn entities(&self) -> EntityCollection { - EntityCollection { - session: self.session.clone(), - } - } - - /// Current geometry revision (query, no bump). - pub fn revision(&self) -> ApiResult { - match self.session.one_query(Query::GetGeometryRevision)? { - QueryResult::Revision(r) => Ok(r), - _ => Err(ApiError::Transport("unexpected revision result".into())), - } - } - - /// Standalone read-guard: fail with `ApiError::Validation` if the document's - /// revision moved past `expected` (read-modify-write guard; NOT a batch pre-condition). - pub fn assert_revision(&self, expected: GeometryRevision) -> ApiResult<()> { - let now = self.revision()?; - if now == expected { - Ok(()) - } else { - Err(ApiError::Validation { - op: "assert_revision".to_string(), - reason: format!( - "revision moved: expected {expected:?}, now {now:?}; re-read and retry" - ), - }) - } - } - - /// Read-only traversal of a block definition's entities (ids + kinds + bounds). - pub fn block_entities(&self, block_name: &str) -> ApiResult> { - match self.session.one_query(Query::GetBlockEntities { - block_name: block_name.to_string(), - })? { - QueryResult::BlockEntities(v) => Ok(v), - _ => Err(ApiError::Transport( - "unexpected block-entities result".into(), - )), - } - } - - /// List all layers in the document. - pub fn layers(&self) -> ApiResult> { - match self.session.one_query(Query::ListLayers)? { - QueryResult::Layers(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected layers result".into())), - } - } - - /// Create a new layer. One undo step; fails if the normalized name already - /// exists or is empty. - pub fn create_layer(&self, info: &crate::ops::LayerInfo) -> ApiResult<()> { - if !crate::layer::is_valid_layer_name(&info.name) { - return Err(ApiError::validation( - "CreateLayer", - "empty layer name", - )); - } - self.session - .apply_op(Operation::CreateLayer(info.clone()))?; - Ok(()) - } - - /// Update an existing layer's properties by name. One undo step; `info.name` - /// becomes the new display name (case-insensitive key unchanged). - pub fn update_layer(&self, name: &str, info: &crate::ops::LayerInfo) -> ApiResult<()> { - if !crate::layer::is_valid_layer_name(&info.name) { - return Err(ApiError::validation( - "UpdateLayer", - "empty layer name", - )); - } - self.session.apply_op(Operation::UpdateLayer { - name: name.to_string(), - info: info.clone(), - })?; - Ok(()) - } - - /// Delete a layer by name. One undo step; fails for layer "0", the current - /// layer, or any layer still referenced by entities. - pub fn delete_layer(&self, name: &str) -> ApiResult<()> { - self.session.apply_op(Operation::DeleteLayer { - name: name.to_string(), - })?; - Ok(()) - } - - /// Set XDATA on many entities in a single atomic operation. - /// - /// Each tuple is `(entity_id, application_name, record)`. Pass `None` for - /// `record` to delete the application's data. Fails up-front if any id is - /// invalid; the whole batch is one undo step. - pub fn set_xdata_many(&self, items: I) -> ApiResult<()> - where - I: IntoIterator)>, - S: Into, - { - let updates: Vec<_> = items - .into_iter() - .map(|(id, app, record)| (id, app.into(), record)) - .collect(); - if updates.is_empty() { - return Ok(()); - } - self.session.apply_op(Operation::SetXDataMany(updates))?; - Ok(()) - } - - /// Batch of read-only queries in ONE round-trip (safe: no mutation/undo). - /// The closure records queries on a [`QueryBatch`]; the results are returned - /// in the same order as a [`QueryResults`] view the caller destructures. - pub fn query_batch(&self, f: F) -> ApiResult { - let mut qb = QueryBatch { - queries: Vec::new(), - session: self.session.clone(), - error: None, - }; - f(&mut qb); - if let Some(error) = qb.error { - return Err(error); - } - let receipt = self.session.apply_queries(qb.queries)?; - Ok(QueryResults { - results: receipt.query_results, - }) - } -} - -/// Records queries for [`Document::query_batch`]. Each method appends one query. -pub struct QueryBatch { - queries: Vec, - session: Session, - error: Option, -} - -impl QueryBatch { - fn check(&mut self, e: &impl HasId) { - if let Err(error) = e.validate_session(&self.session) { - self.error = Some(error); - } - } - pub fn bounds(&mut self, e: &impl HasId) { - self.check(e); - self.queries.push(Query::GetBounds { id: e.id() }); - } - pub fn volume(&mut self, e: &impl HasId) { - self.check(e); - self.queries.push(Query::GetVolume { id: e.id() }); - } - pub fn centroid(&mut self, e: &impl HasId) { - self.check(e); - self.queries.push(Query::GetCentroid { id: e.id() }); - } - pub fn intersects(&mut self, a: &impl HasId, b: &impl HasId) { - self.check(a); - self.check(b); - self.queries.push(Query::GetIntersects { - a: a.id(), - b: b.id(), - }); - } - pub fn revision(&mut self) { - self.queries.push(Query::GetGeometryRevision); - } -} - -/// Ordered results of a [`Document::query_batch`], one per recorded query. -pub struct QueryResults { - results: Vec, -} - -impl QueryResults { - fn at(&self, i: usize) -> ApiResult<&QueryResult> { - self.results - .get(i) - .ok_or_else(|| ApiError::Transport("query result missing".into())) - } - pub fn bounds(&self, i: usize) -> ApiResult { - match self.at(i)? { - QueryResult::Bounds(b) => Ok(*b), - _ => Err(ApiError::Transport("result is not bounds".into())), - } - } - pub fn volume(&self, i: usize) -> ApiResult { - match self.at(i)? { - QueryResult::Volume(v) => Ok(*v), - _ => Err(ApiError::Transport("result is not volume".into())), - } - } - pub fn centroid(&self, i: usize) -> ApiResult<[f64; 3]> { - match self.at(i)? { - QueryResult::Centroid(c) => Ok(*c), - _ => Err(ApiError::Transport("result is not centroid".into())), - } - } - pub fn intersects(&self, i: usize) -> ApiResult { - match self.at(i)? { - QueryResult::Intersects(x) => Ok(*x), - _ => Err(ApiError::Transport("result is not intersects".into())), - } - } - pub fn revision(&self, i: usize) -> ApiResult { - match self.at(i)? { - QueryResult::Revision(r) => Ok(*r), - _ => Err(ApiError::Transport("result is not revision".into())), - } - } -} - -/// Something with an `ObjectId` (all handles + raw ids). -pub trait HasId { - fn id(&self) -> ObjectId; - /// Typed handles retain their session; raw ids are relative to the receiver. - fn session(&self) -> Option<&Session> { - None - } - fn validate_session(&self, target: &Session) -> ApiResult<()> { - if let Some(session) = self.session() { - session.same_transport(target)?; - } - Ok(()) - } -} -impl HasId for ObjectId { - fn id(&self) -> ObjectId { - *self - } -} - -// ── Typed handles ──────────────────────────────────────────────────────────── - -macro_rules! handle { - ($name:ident) => { - #[derive(Clone)] - pub struct $name { - session: Session, - id: ObjectId, - } - impl std::fmt::Debug for $name { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, concat!(stringify!($name), "({:?})"), self.id) - } - } - impl $name { - fn new(session: Session, id: ObjectId) -> Self { - Self { session, id } - } - } - impl HasId for $name { - fn session(&self) -> Option<&Session> { - Some(&self.session) - } - fn id(&self) -> ObjectId { - self.id - } - } - }; -} - -macro_rules! entity_handle { - ($name:ident) => { - handle!($name); - impl $name { - pub fn bounds(&self) -> ApiResult { - match self.session.one_query(Query::GetBounds { id: self.id })? { - QueryResult::Bounds(b) => Ok(b), - _ => Err(ApiError::Transport("unexpected bounds result".into())), - } - } - pub fn delete(&self) -> ApiResult<()> { - self.session.apply_op(Operation::Delete { id: self.id })?; - Ok(()) - } - pub fn transform(&self, placement: PlacementSpec) -> ApiResult<()> { - self.session.apply_op(Operation::Transform { - id: self.id, - placement, - })?; - Ok(()) - } - /// Return the entity's current layer name (normalized uppercase). - pub fn layer(&self) -> ApiResult { - match self - .session - .one_query(Query::GetEntityLayer { id: self.id })? - { - QueryResult::EntityLayer(name) => Ok(name), - _ => Err(ApiError::Transport("unexpected entity layer result".into())), - } - } - /// Move the entity to a different layer. One undo step; fails if the - /// target layer does not exist or is locked. - pub fn set_layer(&self, layer: &str) -> ApiResult<()> { - self.session.apply_op(Operation::SetEntityLayer { - id: self.id, - layer: layer.to_string(), - })?; - Ok(()) - } - } - }; -} - -entity_handle!(Entity); -entity_handle!(Solid); -entity_handle!(Line); -entity_handle!(Circle); -entity_handle!(Polyline); -entity_handle!(Point); -entity_handle!(ArcCurve); -entity_handle!(Ellipse); -entity_handle!(Spline); -entity_handle!(Ray); -entity_handle!(XLine); -entity_handle!(Text); -entity_handle!(MText); -entity_handle!(Dimension); -handle!(XRecord); - -impl XRecord { - /// Read the XRECORD payload. - pub fn payload(&self) -> ApiResult { - match self.session.one_query(Query::GetXRecord { id: self.id })? { - QueryResult::XRecord(spec) => Ok(spec), - _ => Err(ApiError::Transport("unexpected xrecord result".into())), - } - } - /// Replace the XRECORD payload in place (one undo step). - pub fn set_payload(&self, spec: &XRecordSpec) -> ApiResult<()> { - self.session.apply_op(Operation::SetXRecord { - id: self.id, - spec: spec.clone(), - })?; - Ok(()) - } -} - -impl Dimension { - /// The measured value of this dimension (distance for linear/radius, degrees - /// for angular). - pub fn measurement(&self) -> ApiResult { - match self - .session - .one_query(Query::GetDimensionMeasurement { id: self.id })? - { - QueryResult::DimensionMeasurement(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected measurement result".into())), - } - } -} - -impl Entity { - pub fn view(&self) -> ApiResult { - match self.session.one_query(Query::GetEntity { id: self.id })? { - QueryResult::Entity(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected entity result".into())), - } - } - /// This insert's attributes as (tag, value) pairs. Insert-only. - pub fn attributes(&self) -> ApiResult> { - match self - .session - .one_query(Query::GetAttributes { id: self.id })? - { - QueryResult::Attributes(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected attributes result".into())), - } - } - /// Set an attribute `value` for `tag` on this insert (adds if absent). One undo step. - pub fn set_attribute(&self, tag: &str, value: &str) -> ApiResult<()> { - self.session.apply_op(Operation::SetAttribute { - id: self.id, - tag: tag.to_string(), - value: value.to_string(), - })?; - Ok(()) - } - /// This viewport's view (target WCS + zoom height). Viewport-only. - pub fn viewport_view(&self) -> ApiResult<([f64; 3], f64)> { - match self - .session - .one_query(Query::GetViewportView { id: self.id })? - { - QueryResult::ViewportView { target, height } => Ok((target, height)), - _ => Err(ApiError::Transport( - "unexpected viewport-view result".into(), - )), - } - } - /// Retarget / re-zoom this viewport (one undo step). Viewport-only. - pub fn set_view(&self, view_target: [f64; 3], view_height: f64) -> ApiResult<()> { - self.session.apply_op(Operation::SetViewportView { - id: self.id, - view_target, - view_height, - })?; - Ok(()) - } - /// The XDATA record for `application_name` on this entity (`None` if absent). - pub fn xdata(&self, application_name: &str) -> ApiResult> { - match self.session.one_query(Query::GetXData { - id: self.id, - application_name: application_name.to_string(), - })? { - QueryResult::XData(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected xdata result".into())), - } - } - /// Attach or replace an XDATA record for `application_name` on this entity. - /// Pass `None` to remove the record (one undo step). - pub fn set_xdata(&self, application_name: &str, record: Option) -> ApiResult<()> { - self.session.apply_op(Operation::SetXData { - id: self.id, - application_name: application_name.to_string(), - record, - })?; - Ok(()) - } - pub fn as_solid(&self) -> Option { - // Typed downcast is validated by the view's kind. - matches!(self.view().ok()?.kind.as_str(), "Solid3D") - .then(|| Solid::new(self.session.clone(), self.id)) - } -} - -impl Solid { - fn boolean(&self, op: BoolOp, other: &Solid) -> ApiResult { - self.session.same_transport(&other.session)?; - let receipt = self.session.apply_op(Operation::SolidBoolean { - op, - a: self.id, - b: other.id, - erase_sources: true, - })?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("boolean returned no id".into()))?; - Ok(Solid::new(self.session.clone(), id)) - } - pub fn intersect(&self, other: &Solid) -> ApiResult { - self.boolean(BoolOp::Intersection, other) - } - pub fn union(&self, other: &Solid) -> ApiResult { - self.boolean(BoolOp::Union, other) - } - pub fn subtract(&self, other: &Solid) -> ApiResult { - self.boolean(BoolOp::Difference, other) - } - pub fn volume(&self) -> ApiResult { - match self.session.one_query(Query::GetVolume { id: self.id })? { - QueryResult::Volume(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected volume result".into())), - } - } - pub fn centroid(&self) -> ApiResult<[f64; 3]> { - match self.session.one_query(Query::GetCentroid { id: self.id })? { - QueryResult::Centroid(c) => Ok(c), - _ => Err(ApiError::Transport("unexpected centroid result".into())), - } - } - pub fn intersects(&self, other: &Solid) -> ApiResult { - self.session.same_transport(&other.session)?; - match self.session.one_query(Query::GetIntersects { - a: self.id, - b: other.id, - })? { - QueryResult::Intersects(x) => Ok(x), - _ => Err(ApiError::Transport("unexpected intersects result".into())), - } - } -} - -impl Polyline { - pub fn add_vertex(&self, at: usize, point: [f64; 3]) -> ApiResult<()> { - self.session.apply_op(Operation::AddVertex { - id: self.id, - at, - point, - })?; - Ok(()) - } -} - -macro_rules! text_handle { - ($name:ident) => { - impl $name { - /// The text content of this annotation. - pub fn content(&self) -> ApiResult { - match self - .session - .one_query(Query::GetTextContent { id: self.id })? - { - QueryResult::TextContent(s) => Ok(s), - _ => Err(ApiError::Transport("unexpected content result".into())), - } - } - /// Replace the text content (one undo step). - pub fn set_content(&self, value: &str) -> ApiResult<()> { - self.session.apply_op(Operation::SetTextContent { - id: self.id, - value: value.to_string(), - })?; - Ok(()) - } - } - }; -} -text_handle!(Text); -text_handle!(MText); - -// ── Collections (construction / lookup) ───────────────────────────────────── - -#[derive(Clone)] -pub struct SolidCollection { - session: Session, -} - -impl SolidCollection { - fn create(&self, prim: SolidPrimitive) -> ApiResult { - let receipt = self.session.apply_op(Operation::CreateSolid(prim))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_solid returned no id".into()))?; - Ok(Solid::new(self.session.clone(), id)) - } - pub fn create_cuboid(&self, origin: [f64; 3], size: [f64; 3]) -> ApiResult { - self.create(SolidPrimitive::Cuboid { origin, size }) - } - pub fn create_sphere(&self, centre: [f64; 3], radius: f64) -> ApiResult { - self.create(SolidPrimitive::Sphere { centre, radius }) - } - pub fn create_cylinder(&self, base: [f64; 3], radius: f64, height: f64) -> ApiResult { - self.create(SolidPrimitive::Cylinder { - base, - radius, - height, - }) - } - pub fn create_cone(&self, base: [f64; 3], radius: f64, height: f64) -> ApiResult { - self.create(SolidPrimitive::Cone { - base, - radius, - height, - }) - } - pub fn create_torus( - &self, - centre: [f64; 3], - major_radius: f64, - minor_radius: f64, - ) -> ApiResult { - self.create(SolidPrimitive::Torus { - centre, - major_radius, - minor_radius, - }) - } - pub fn create_wedge(&self, origin: [f64; 3], size: [f64; 3]) -> ApiResult { - self.create(SolidPrimitive::Wedge { origin, size }) - } - pub fn extrude(&self, profile: &impl HasId, direction: [f64; 3]) -> ApiResult { - profile.validate_session(&self.session)?; - let receipt = self.session.apply_op(Operation::Extrude { - profile: profile.id(), - direction, - })?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("extrude returned no id".into()))?; - Ok(Solid::new(self.session.clone(), id)) - } - pub fn revolve( - &self, - profile: &impl HasId, - pivot: [f64; 3], - axis: [f64; 3], - angle: f64, - ) -> ApiResult { - profile.validate_session(&self.session)?; - let receipt = self.session.apply_op(Operation::Revolve { - profile: profile.id(), - axis: (pivot, axis), - angle, - })?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("revolve returned no id".into()))?; - Ok(Solid::new(self.session.clone(), id)) - } - /// Loft a solid through >= 2 profile entities (polylines/circles/arcs). - pub fn loft(&self, profiles: &[impl HasId]) -> ApiResult { - for profile in profiles { - profile.validate_session(&self.session)?; - } - let ids: Vec = profiles.iter().map(|p| p.id()).collect(); - let receipt = self.session.apply_op(Operation::Loft { profiles: ids })?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("loft returned no id".into()))?; - Ok(Solid::new(self.session.clone(), id)) - } -} - -#[derive(Clone)] -pub struct CurveCollection { - session: Session, -} - -impl CurveCollection { - fn create_curve(&self, spec: Curve2Spec) -> ApiResult<(Session, ObjectId)> { - let receipt = self.session.apply_op(Operation::CreateCurve(spec))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_curve returned no id".into()))?; - Ok((self.session.clone(), id)) - } - pub fn create_line(&self, start: [f64; 3], end: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Line { - start, - end, - layer: None, - })?; - Ok(Line::new(s, id)) - } - pub fn create_line_on_layer( - &self, - start: [f64; 3], - end: [f64; 3], - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Line { - start, - end, - layer: Some(layer.to_string()), - })?; - Ok(Line::new(s, id)) - } - pub fn create_circle(&self, centre: [f64; 3], radius: f64) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Circle { - centre, - radius, - layer: None, - })?; - Ok(Circle::new(s, id)) - } - pub fn create_circle_on_layer( - &self, - centre: [f64; 3], - radius: f64, - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Circle { - centre, - radius, - layer: Some(layer.to_string()), - })?; - Ok(Circle::new(s, id)) - } - pub fn create_polyline(&self, points: &[[f64; 3]], closed: bool) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Polyline { - points: points.to_vec(), - closed, - layer: None, - })?; - Ok(Polyline::new(s, id)) - } - pub fn create_polyline_on_layer( - &self, - points: &[[f64; 3]], - closed: bool, - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Polyline { - points: points.to_vec(), - closed, - layer: Some(layer.to_string()), - })?; - Ok(Polyline::new(s, id)) - } - pub fn create_point(&self, position: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Point { - position, - layer: None, - })?; - Ok(Point::new(s, id)) - } - pub fn create_point_on_layer(&self, position: [f64; 3], layer: &str) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Point { - position, - layer: Some(layer.to_string()), - })?; - Ok(Point::new(s, id)) - } - pub fn create_arc( - &self, - centre: [f64; 3], - radius: f64, - start_angle: f64, - end_angle: f64, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Arc { - centre, - radius, - start_angle, - end_angle, - layer: None, - })?; - Ok(ArcCurve::new(s, id)) - } - pub fn create_arc_on_layer( - &self, - centre: [f64; 3], - radius: f64, - start_angle: f64, - end_angle: f64, - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Arc { - centre, - radius, - start_angle, - end_angle, - layer: Some(layer.to_string()), - })?; - Ok(ArcCurve::new(s, id)) - } - pub fn create_ellipse( - &self, - centre: [f64; 3], - major_axis: [f64; 3], - ratio: f64, - start: f64, - end: f64, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Ellipse { - centre, - major_axis, - ratio, - start, - end, - layer: None, - })?; - Ok(Ellipse::new(s, id)) - } - pub fn create_ellipse_on_layer( - &self, - centre: [f64; 3], - major_axis: [f64; 3], - ratio: f64, - start: f64, - end: f64, - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Ellipse { - centre, - major_axis, - ratio, - start, - end, - layer: Some(layer.to_string()), - })?; - Ok(Ellipse::new(s, id)) - } - pub fn create_spline( - &self, - degree: i32, - control_points: &[[f64; 3]], - knots: &[f64], - weights: &[f64], - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Spline { - degree, - control_points: control_points.to_vec(), - knots: knots.to_vec(), - weights: weights.to_vec(), - layer: None, - })?; - Ok(Spline::new(s, id)) - } - pub fn create_spline_on_layer( - &self, - degree: i32, - control_points: &[[f64; 3]], - knots: &[f64], - weights: &[f64], - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Spline { - degree, - control_points: control_points.to_vec(), - knots: knots.to_vec(), - weights: weights.to_vec(), - layer: Some(layer.to_string()), - })?; - Ok(Spline::new(s, id)) - } - pub fn create_ray(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Ray { - origin, - direction, - layer: None, - })?; - Ok(Ray::new(s, id)) - } - pub fn create_ray_on_layer( - &self, - origin: [f64; 3], - direction: [f64; 3], - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Ray { - origin, - direction, - layer: Some(layer.to_string()), - })?; - Ok(Ray::new(s, id)) - } - pub fn create_xline(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::XLine { - origin, - direction, - layer: None, - })?; - Ok(XLine::new(s, id)) - } - pub fn create_xline_on_layer( - &self, - origin: [f64; 3], - direction: [f64; 3], - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::XLine { - origin, - direction, - layer: Some(layer.to_string()), - })?; - Ok(XLine::new(s, id)) - } - /// A RASTER_IMAGE placed at `insertion_point` (host auto-registers the image definition). - pub fn create_raster_image( - &self, - file_path: &str, - insertion_point: [f64; 3], - u_vector: [f64; 3], - v_vector: [f64; 3], - size: [f64; 2], - ) -> ApiResult { - let receipt = - self.session - .apply_op(Operation::CreateRasterImage(crate::ops::RasterImageSpec { - file_path: file_path.to_string(), - insertion_point, - u_vector, - v_vector, - size, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_raster_image returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// A solid (or pattern) HATCH over a single closed polyline boundary. - pub fn create_hatch(&self, boundary: &[[f64; 2]], solid: bool) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateHatch(crate::ops::HatchSpec { - boundary: boundary.to_vec(), - solid, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_hatch returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// A 2-line angular DIMENSION: angle between lines (vertex→first) and (vertex→second). - pub fn create_dimension_angular2ln( - &self, - vertex: [f64; 3], - first_point: [f64; 3], - second_point: [f64; 3], - arc_location: [f64; 3], - ) -> ApiResult { - let receipt = self.session.apply_op(Operation::CreateDimensionAngular2Ln( - crate::ops::DimensionAngularSpec { - vertex, - first_point, - second_point, - arc_location, - }, - ))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?; - Ok(Dimension::new(self.session.clone(), id)) - } - /// A radial DIMENSION for the circle centered at `center` through `point`. - pub fn create_dimension_radius( - &self, - center: [f64; 3], - point: [f64; 3], - ) -> ApiResult { - self.create_dim_radial(Operation::CreateDimensionRadius, center, point) - } - /// A diameter DIMENSION for the circle with chord points `center`/`point`. - pub fn create_dimension_diameter( - &self, - center: [f64; 3], - point: [f64; 3], - ) -> ApiResult { - self.create_dim_radial(Operation::CreateDimensionDiameter, center, point) - } - fn create_dim_radial( - &self, - op: impl Fn(crate::ops::DimensionRadialSpec) -> Operation, - center: [f64; 3], - point: [f64; 3], - ) -> ApiResult { - let receipt = self - .session - .apply_op(op(crate::ops::DimensionRadialSpec { center, point }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?; - Ok(Dimension::new(self.session.clone(), id)) - } - /// A 3-point angular DIMENSION (vertex + one point on each leg). - pub fn create_dimension_angular( - &self, - vertex: [f64; 3], - first_point: [f64; 3], - second_point: [f64; 3], - arc_location: [f64; 3], - ) -> ApiResult { - let receipt = self.session.apply_op(Operation::CreateDimensionAngular( - crate::ops::DimensionAngularSpec { - vertex, - first_point, - second_point, - arc_location, - }, - ))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?; - Ok(Dimension::new(self.session.clone(), id)) - } - /// A linear DIMENSION between `first_point` and `second_point`, with the - /// dimension line placed at `definition_point`. `measurement()` reads the value. - pub fn create_dimension_linear( - &self, - first_point: [f64; 3], - second_point: [f64; 3], - definition_point: [f64; 3], - ) -> ApiResult { - let receipt = self.session.apply_op(Operation::CreateDimensionLinear( - crate::ops::DimensionSpec { - first_point, - second_point, - definition_point, - }, - ))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?; - Ok(Dimension::new(self.session.clone(), id)) - } - - /// Single-line TEXT annotation. - pub fn create_text( - &self, - value: &str, - insertion_point: [f64; 3], - height: f64, - rotation: f64, - ) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateText(crate::ops::TextSpec { - value: value.to_string(), - insertion_point, - height, - rotation, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_text returned no id".into()))?; - Ok(Text::new(self.session.clone(), id)) - } - /// Multi-line MTEXT annotation. - pub fn create_mtext( - &self, - value: &str, - insertion_point: [f64; 3], - height: f64, - ) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateMText(crate::ops::MTextSpec { - value: value.to_string(), - insertion_point, - height, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_mtext returned no id".into()))?; - Ok(MText::new(self.session.clone(), id)) - } - /// Bulk-create many points in ONE op: all-or-nothing, one undo step. - /// Places points on the current layer (`layer: None`). - pub fn create_points(&self, positions: &[[f64; 3]]) -> ApiResult> { - let specs: Vec = positions - .iter() - .map(|&p| { - crate::ops::EntitySpec::Curve(crate::ops::Curve2Spec::Point { - position: p, - layer: None, - }) - }) - .collect(); - let receipt = self.session.apply_op(Operation::CreateMany(specs))?; - let outcome = receipt - .outcome - .ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?; - Ok(outcome - .new_ids() - .iter() - .map(|&id| Point::new(self.session.clone(), id)) - .collect()) - } - - /// Bulk-create many points on a specific layer in ONE op. - pub fn create_points_on_layer( - &self, - positions: &[[f64; 3]], - layer: &str, - ) -> ApiResult> { - let specs: Vec = positions - .iter() - .map(|&p| { - crate::ops::EntitySpec::Curve(crate::ops::Curve2Spec::Point { - position: p, - layer: Some(layer.to_string()), - }) - }) - .collect(); - let receipt = self.session.apply_op(Operation::CreateMany(specs))?; - let outcome = receipt - .outcome - .ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?; - Ok(outcome - .new_ids() - .iter() - .map(|&id| Point::new(self.session.clone(), id)) - .collect()) - } -} - -#[derive(Clone)] -pub struct EntityCollection { - session: Session, -} - -impl EntityCollection { - /// Create a paper-space `VIEWPORT` (a `width`×`height` viewport at `center` - /// looking at `view_target` with `view_height` zoom). - pub fn create_viewport( - &self, - center: [f64; 3], - width: f64, - height: f64, - view_target: [f64; 3], - view_height: f64, - ) -> ApiResult { - let receipt = - self.session - .apply_op(Operation::CreateViewport(crate::ops::ViewportSpec { - center, - width, - height, - view_target, - view_height, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_viewport returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// Create a TABLE from a grid of cell strings (`data[row][column]`), at a point. - pub fn create_table( - &self, - insertion_point: [f64; 3], - data: &[Vec], - ) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateTable(crate::ops::TableSpec { - insertion_point, - data: data.to_vec(), - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_table returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// Create an ATTDEF (in-block attribute definition): tag/prompt/default at a point. - pub fn create_attribute_definition( - &self, - tag: &str, - prompt: &str, - default_value: &str, - insertion_point: [f64; 3], - height: f64, - rotation: f64, - ) -> ApiResult { - let receipt = self.session.apply_op(Operation::CreateAttributeDefinition( - crate::ops::AttributeDefinitionSpec { - tag: tag.to_string(), - prompt: prompt.to_string(), - default_value: default_value.to_string(), - insertion_point, - height, - rotation, - }, - ))?; - let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| { - ApiError::Transport("create_attribute_definition returned no id".into()) - })?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// Place a block reference (`INSERT`) for `block_name` (must exist). - pub fn create_insert( - &self, - block_name: &str, - insert_point: [f64; 3], - scale: f64, - rotation: f64, - ) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateInsert(crate::ops::InsertSpec { - block_name: block_name.to_string(), - insert_point, - scale, - rotation, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_insert returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// Generic lookup by id (any family) → `Entity` (downcast via `as_solid()`, …). - pub fn get(&self, id: ObjectId) -> ApiResult { - // Validate existence eagerly so `get` errors surface as `UnknownId`. - self.session.one_query(Query::GetEntity { id })?; - Ok(Entity::new(self.session.clone(), id)) - } - pub fn delete(&self, id: ObjectId) -> ApiResult<()> { - self.session.apply_op(Operation::Delete { id })?; - Ok(()) - } - - /// Create a standalone `XRECORD` object. - pub fn create_xrecord(&self, spec: &XRecordSpec) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateXRecord(spec.clone()))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_xrecord returned no id".into()))?; - Ok(XRecord::new(self.session.clone(), id)) - } - - /// Lookup an `XRECORD` object by id and return a typed accessor handle. - pub fn get_xrecord(&self, id: ObjectId) -> ApiResult { - // Validate existence + that the payload is readable as an XRecord. - match self.session.one_query(Query::GetXRecord { id })? { - QueryResult::XRecord(_) => Ok(XRecord::new(self.session.clone(), id)), - _ => Err(ApiError::Transport("unexpected xrecord result".into())), - } - } - - /// Bulk-create many entities in ONE op (all-or-nothing, one undo step). - pub fn create_many(&self, specs: &[crate::ops::EntitySpec]) -> ApiResult> { - let receipt = self.session.apply_op(Operation::CreateMany(specs.to_vec()))?; - let outcome = receipt - .outcome - .ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?; - Ok(outcome - .new_ids() - .iter() - .map(|&id| Entity::new(self.session.clone(), id)) - .collect()) - } - - /// Bulk transform (one op, all-or-nothing). - pub fn transform_many(&self, ids: &[ObjectId], placement: PlacementSpec) -> ApiResult<()> { - self.session.apply_op(Operation::TransformMany { - ids: ids.to_vec(), - placement, - })?; - Ok(()) - } - /// Bulk delete (one op, all-or-nothing). Used by `OpGroup::compensate`. - pub fn delete_many(&self, ids: &[ObjectId]) -> ApiResult<()> { - self.session.apply_op(Operation::DeleteMany(ids.to_vec()))?; - Ok(()) - } -} - -// ── OpGroup: client-side failure-cleanup (NOT a transaction) ──────────────── - -/// Tracks handles created during a logical operation so a later failure can be -/// cleaned up with a bulk delete. Separate writes remain committed; -/// no host involvement, no rollback. -#[derive(Default)] -pub struct OpGroup { - created: Vec, - session: Option, -} - -impl OpGroup { - pub fn new() -> Self { - Self { - created: Vec::new(), - session: None, - } - } - /// Record a created handle (propagating any construction error); returns it - /// for fluent `grp.track(doc.solids().create_cuboid(..)?)?` use. - pub fn track(&mut self, handle: ApiResult) -> ApiResult { - let handle = handle?; - if let Some(session) = handle.session() { - if let Some(previous) = &self.session { - previous.same_transport(session)?; - } else { - self.session = Some(session.clone()); - } - } - if !self.created.contains(&handle.id()) { - self.created.push(handle.id()); - } - Ok(handle) - } - /// Keep everything: drop the tracking without deleting. - pub fn commit(self) {} - /// Best-effort cleanup: delete every tracked entity (one `DeleteMany` op). - /// Does not resurrect already-consumed/deleted entities. - pub fn compensate(self, doc: &Document) -> ApiResult<()> { - if let Some(session) = &self.session { - session.same_transport(&doc.session)?; - } - if self.created.is_empty() { - return Ok(()); - } - doc.entities().delete_many(&self.created) - } -} diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md deleted file mode 100644 index a0396caa1..000000000 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ /dev/null @@ -1,382 +0,0 @@ -# ocs_doc_api — API reference (auto-generated) - -Envelope version: `1` - -Do NOT edit: regenerated by `build.rs` from `spec/entities.toml`. - -## `Solid` (acadrust `Solid3D`, collection `solids`) - -B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from ACIS SAT). - -### Constructors (`doc.solids()`) - -- **create_cuboid**(origin: [f64; 3], size: [f64; 3]) -> `Solid` — `CreateSolid`; fixed primitive="Cuboid" -- **create_sphere**(centre: [f64; 3], radius: f64) -> `Solid` — `CreateSolid`; fixed primitive="Sphere" -- **create_cylinder**(base: [f64; 3], radius: f64, height: f64) -> `Solid` — `CreateSolid`; fixed primitive="Cylinder" -- **create_cone**(base: [f64; 3], radius: f64, height: f64) -> `Solid` — `CreateSolid`; fixed primitive="Cone" -- **create_torus**(centre: [f64; 3], major_radius: f64, minor_radius: f64) -> `Solid` — `CreateSolid`; fixed primitive="Torus" -- **create_wedge**(origin: [f64; 3], size: [f64; 3]) -> `Solid` — `CreateSolid`; fixed primitive="Wedge" -- **extrude**(profile: EntityRef, direction: [f64; 3]) -> `Solid` — `Extrude` -- **revolve**(profile: EntityRef, pivot: [f64; 3], axis: [f64; 3], angle: f64) -> `Solid` — `Revolve` -- **loft**(profiles: &[EntityRef]) -> `Solid` — `Loft` - -### Methods - -- **intersect**(other: Solid) -> `Solid` — op `SolidBoolean`; fixed erase_sources=true, op="Intersection" -- **union**(other: Solid) -> `Solid` — op `SolidBoolean`; fixed erase_sources=true, op="Union" -- **subtract**(other: Solid) -> `Solid` — op `SolidBoolean`; fixed erase_sources=true, op="Difference" -- **volume**() -> `f64` — query `GetVolume` -- **centroid**() -> `[f64; 3]` — query `GetCentroid` -- **intersects**(other: Solid) -> `bool` — query `GetIntersects` - -## `Line` (acadrust `Line`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_line**(start: [f64; 3], end: [f64; 3]) -> `Line` — `CreateCurve`; fixed curve="Line" - -## `Circle` (acadrust `Circle`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_circle**(centre: [f64; 3], radius: f64) -> `Circle` — `CreateCurve`; fixed curve="Circle" - -## `Polyline` (acadrust `LwPolyline`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_polyline**(points: &[[f64; 3]], closed: bool) -> `Polyline` — `CreateCurve`; fixed curve="Polyline" - -### Methods - -- **add_vertex**(at: usize, point: [f64; 3]) -> `()` — op `AddVertex` - -## `Point` (acadrust `Point`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_point**(position: [f64; 3]) -> `Point` — `CreateCurve`; fixed curve="Point" -- **create_points**(positions: &[[f64; 3]]) -> `Vec` *(bulk op)* — `CreateMany`; fixed curve="Point" - -## `ArcCurve` (acadrust `Arc`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_arc**(centre: [f64; 3], radius: f64, start_angle: f64, end_angle: f64) -> `ArcCurve` — `CreateCurve`; fixed curve="Arc" - -## `Ellipse` (acadrust `Ellipse`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_ellipse**(centre: [f64; 3], major_axis: [f64; 3], ratio: f64, start: f64, end: f64) -> `Ellipse` — `CreateCurve`; fixed curve="Ellipse" - -## `Spline` (acadrust `Spline`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_spline**(degree: i32, control_points: &[[f64; 3]], knots: &[f64], weights: &[f64]) -> `Spline` — `CreateCurve`; fixed curve="Spline" - -## `Ray` (acadrust `Ray`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_ray**(origin: [f64; 3], direction: [f64; 3]) -> `Ray` — `CreateCurve`; fixed curve="Ray" - -## `XLine` (acadrust `XLine`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_xline**(origin: [f64; 3], direction: [f64; 3]) -> `XLine` — `CreateCurve`; fixed curve="XLine" - -## `Text` (acadrust `Text`, collection `curves`) - -Single-line text annotation. content()/set_content() read+replace the value; transform moves insertion point. - -### Constructors (`doc.curves()`) - -- **create_text**(value: &str, insertion_point: [f64; 3], height: f64, rotation: f64) -> `Text` — `CreateText` - -### Methods - -- **content**() -> `String` — query `GetTextContent` -- **set_content**(value: &str) -> `()` — op `SetTextContent` - -## `MText` (acadrust `MText`, collection `curves`) - -Multi-line text annotation. content()/set_content() read+replace the value; transform moves insertion point. - -### Constructors (`doc.curves()`) - -- **create_mtext**(value: &str, insertion_point: [f64; 3], height: f64) -> `MText` — `CreateMText` - -### Methods - -- **content**() -> `String` — query `GetTextContent` -- **set_content**(value: &str) -> `()` — op `SetTextContent` - -## `Entity` (acadrust `Hatch`, collection `curves`) - -Filled region over a closed polyline boundary. boundary() returns the loops; bounds from boundary; generic delete. - -### Constructors (`doc.curves()`) - -- **create_hatch**(boundary: &[[f64; 2]], solid: bool) -> `Entity` — `CreateHatch` - -### Methods - -- **boundary**() -> `Vec>` — query `GetHatchBoundary` - -## `Dimension` (acadrust `Dimension`, collection `curves`) - -Linear dimension between two measured points. measurement() reads the distance; bounds from the measured + definition points. - -### Constructors (`doc.curves()`) - -- **create_dimension_linear**(first_point: [f64; 3], second_point: [f64; 3], definition_point: [f64; 3]) -> `Dimension` — `CreateDimensionLinear` -- **create_dimension_radius**(center: [f64; 3], point: [f64; 3]) -> `Dimension` — `CreateDimensionRadius` -- **create_dimension_diameter**(center: [f64; 3], point: [f64; 3]) -> `Dimension` — `CreateDimensionDiameter` -- **create_dimension_angular**(vertex: [f64; 3], first_point: [f64; 3], second_point: [f64; 3], arc_location: [f64; 3]) -> `Dimension` — `CreateDimensionAngular` -- **create_dimension_angular2ln**(vertex: [f64; 3], first_point: [f64; 3], second_point: [f64; 3], arc_location: [f64; 3]) -> `Dimension` — `CreateDimensionAngular2Ln` - -### Methods - -- **measurement**() -> `f64` — query `GetDimensionMeasurement` - -## `Entity` (acadrust `Insert`, collection `entities`) - -Block reference. Place an existing BlockRecord by name; transform moves its insert point. - -### Constructors (`doc.entities()`) - -- **create_insert**(block_name: &str, insert_point: [f64; 3], scale: f64, rotation: f64) -> `Entity` — `CreateInsert` - -### Methods - -- **attributes**() -> `Vec<(String, String)>` — query `GetAttributes` -- **set_attribute**(tag: &str, value: &str) -> `()` — op `SetAttribute` - -## `Entity` (acadrust `AttributeDefinition`, collection `entities`) - -In-block attribute definition (ATTDEF): tag/prompt/default at an insertion point. - -### Constructors (`doc.entities()`) - -- **create_attribute_definition**(tag: &str, prompt: &str, default_value: &str, insertion_point: [f64; 3], height: f64, rotation: f64) -> `Entity` — `CreateAttributeDefinition` - -## `Entity` (acadrust `Viewport`, collection `entities`) - -Paper-space viewport: a width×height window at center looking at view_target with view_height zoom. bounds = center ± w/2,h/2; transform moves center. - -### Constructors (`doc.entities()`) - -- **create_viewport**(center: [f64; 3], width: f64, height: f64, view_target: [f64; 3], view_height: f64) -> `Entity` — `CreateViewport` - -### Methods - -- **viewport_view**() -> `([f64; 3], f64)` — query `GetViewportView` -- **set_view**(view_target: [f64; 3], view_height: f64) -> `()` — op `SetViewportView` - -## `Entity` (acadrust `RasterImage`, collection `entities`) - -Raster image: create_raster_image(file_path, insertion, u, v, size) places an image (host auto-registers the ImageDefinition); bounds (4-corner bracket); delete. - -### Constructors (`doc.entities()`) - -- **create_raster_image**(file_path: &str, insertion_point: [f64; 3], u_vector: [f64; 3], v_vector: [f64; 3], size: [f64; 2]) -> `Entity` — `CreateRasterImage` - -## `Entity` (acadrust `Table`, collection `entities`) - -Table: create_table(insertion_point, data[row][col]) builds rows x columns of text cells; non-rectangular grid -> Validation; delete. - -### Constructors (`doc.entities()`) - -- **create_table**(insertion_point: [f64; 3], data: &[Vec]) -> `Entity` — `CreateTable` - -## `XRecord` (acadrust `XRecord`, collection `entities`) - -Extended record (XRecord): arbitrary DXF group-code/value pairs. Roundtripped verbatim as an XRecordSpec payload. - -### Constructors (`doc.entities()`) - -- **create_xrecord**(spec: XRecordSpec) -> `XRecord` — `CreateXRecord` - -### Methods - -- **payload**() -> `XRecordSpec` — query `GetXRecord` -- **set_payload**(spec: XRecordSpec) -> `()` — op `SetXRecord` - -## `Layer` (acadrust `-`, collection `layers`) - -Named drawing layer (color, linetype, lineweight, visibility). Not an entity handle; accessed through Document::layers(). - -### Methods - -- **list**() -> `Vec` — query `ListLayers` -- **create**(info: LayerInfo) -> `()` — op `CreateLayer` -- **update**(name: String, info: LayerInfo) -> `()` — op `UpdateLayer` -- **delete**(name: String) -> `()` — op `DeleteLayer` - -## Generic methods (every handle) - -Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, -`Ellipse`, `Spline`, `Ray`, `XLine`, `Text`, `MText`, `Dimension`, `Entity`) is a -`Clone`, `Send`, `Sync` token `{ ObjectId, session }` and supports: - -- **`id()` -> `ObjectId`** — the stable entity identity. -- **`bounds()` -> `Aabb`** — coarse bounding box (solids lift-on-miss; 2D entities - computed; later families may be `Unsupported`). -- **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1: - solids only.** -- **`delete()`** — remove the entity (one undo step). -- **`layer()` -> `String`** — the name of the layer the entity is on. -- **`set_layer(layer)`** — move the entity to an existing layer (one undo step; - blocked if the entity or target layer is locked). - -`Entity` additionally has `view()` (id + kind + bounds), `as_solid()` (typed -downcast when `kind == "Solid3D"`), and XDATA access through -`xdata(application_name)` / `set_xdata(application_name, record)`. - -## Layer table - -Layers are a named document table, not an entity family. `Document::layers()` -lists all layers as `Vec`. Table-level ops are `CreateLayer(info)`, -`UpdateLayer { name, info }` and `DeleteLayer { name }`. `Entity::layer()` -returns the layer name and `Entity::set_layer(layer)` moves the entity to an -existing layer. The host rejects duplicate names, deletion of layer "0" or the -current layer, and removing a layer that still has entities assigned. - -## Transports - -- **`InProcess`** (feature `host`) — drives a `DocApiBackend` directly in the host. -- **`OcsPluginApiIpc`** (feature `ipc`) — serializes envelopes over the - `ocs_plugin_api` `PluginRequestSender`/`PluginRequest` channel. The host routes - `DocApiRequest { tab_id, bytes }` to the same executor. -- **`doc_api_for_host(host)`** (feature `doc_api_host`) — convenience helper that - builds a `DocApi` from any `ocs_plugin_api::host::HostApi` exposing a - `PluginRequestSender` (out-of-process / worker-thread plugins). - -## Collections & cross-cutting - -- **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`. -- **`Document`** — `solids()` / `curves()` / `entities()` factories + lookup, - `revision()`, `assert_revision(rev)` (read-guard), `query_batch(|q| …)` (read-only - batch, one round-trip, **no revision bump**). -- **`EntityCollection`** — `get(id) -> Entity`, `delete(id)`, - `transform_many(&ids, placement)`, `delete_many(&ids)`. -- **`OpGroup`** — best-effort client-side failure cleanup (`track` / `commit` / - `compensate`). **Not** a transaction: no atomicity, no rollback. - -## Bulk ops — single op, all-or-nothing, item-capped - -`CreateMany` / `TransformMany` / `DeleteMany` are **single** write ops: one undo -step, one publish, and **all-or-nothing via upfront validation** (no rollback). -Item-capped at `BULK_ITEM_CAP = 100_000` to bound host execution. Use them for -bulk data (N ≳ a few hundred); per-op autocommit is the right tool for -modeling-scale work. - -## Semantics - -- **Per-op autocommit, no write transactions.** Every write op is ONE atomic host - call = one undo step. Multi-op write transactions were removed (security/ - integrity review). -- **Per-op atomicity.** Validate + compute geometry **before** any mutation; a - failed op applies nothing and records no undo step. `erase_sources` booleans run - only on success. -- **Structured errors.** `ApiError::{Validation, Geometry, UnknownId, Unsupported, - Transport}`; bulk validation names the failing index; a stale handle returns - `UnknownId` (never a panic). - -## Kernel mapping - -| op | kernel call | returns | -|---|---|---| -| CreateSolid.Cuboid | `cadkernel::brep::make::cuboid` | Option | -| CreateSolid.Sphere | `cadkernel::brep::make::sphere` | Option | -| SolidBoolean | `cadkernel::brep::combine` | Result | -| Transform | `cadkernel::brep::transform` | Option | -| GetBounds | `cadkernel::brep::body_bounds` | Option | -| GetVolume | `cadkernel::brep::mesh_body + divergence` | computed | - -## Wire vocabulary - -Every `Operation` is ONE atomic write op (one undo step); every `Query` is read-only (safe to batch). Variants are append-only (bincode discriminant stability). Requests ride `DocApiEnvelope`: `Op(Operation)` for a write, `Queries(Vec)` for a read batch. - -### `Operation` - -- `CreateSolid(SolidPrimitive)` -- `CreateCurve(Curve2Spec)` -- `Extrude { profile: ObjectId, direction: [f64; 3] }` -- `Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }` -- `Transform { id: ObjectId, placement: PlacementSpec }` -- `SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }` -- `AddVertex { id: ObjectId, at: usize, point: [f64; 3] }` -- `Delete { id: ObjectId }` -- `CreateMany(Vec)` -- `TransformMany { ids: Vec, placement: PlacementSpec }` -- `DeleteMany(Vec)` -- `CreateInsert(InsertSpec)` -- `CreateViewport(ViewportSpec)` -- `CreateText(TextSpec)` -- `CreateMText(MTextSpec)` -- `SetTextContent { id: ObjectId, value: String }` -- `CreateHatch(HatchSpec)` -- `CreateDimensionLinear(DimensionSpec)` -- `SetAttribute { id: ObjectId, tag: String, value: String }` -- `SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }` -- `CreateRasterImage(RasterImageSpec)` -- `Loft { profiles: Vec }` -- `CreateDimensionRadius(DimensionRadialSpec)` -- `CreateDimensionDiameter(DimensionRadialSpec)` -- `CreateDimensionAngular(DimensionAngularSpec)` -- `CreateAttributeDefinition(AttributeDefinitionSpec)` -- `CreateTable(TableSpec)` -- `CreateDimensionAngular2Ln(DimensionAngularSpec)` -- `SetXData { id: ObjectId, application_name: String, record: Option }` -- `CreateXRecord(XRecordSpec)` -- `SetXRecord { id: ObjectId, spec: XRecordSpec }` -- `CreateLayer(LayerInfo)` -- `UpdateLayer { name: String, info: LayerInfo }` -- `DeleteLayer { name: String }` -- `SetEntityLayer { id: ObjectId, layer: String }` -- `SetXDataMany(Vec<(ObjectId, String, Option)` - -### `Query` - -- `GetEntity { id: ObjectId }` -- `GetBounds { id: ObjectId }` -- `GetCentroid { id: ObjectId }` -- `GetVolume { id: ObjectId }` -- `GetIntersects { a: ObjectId, b: ObjectId }` -- `GetGeometryRevision` -- `GetTextContent { id: ObjectId }` -- `GetHatchBoundary { id: ObjectId }` -- `GetDimensionMeasurement { id: ObjectId }` -- `GetAttributes { id: ObjectId }` -- `GetBlockEntities { block_name: String }` -- `GetViewportView { id: ObjectId }` -- `GetXData { id: ObjectId, application_name: String }` -- `GetXRecord { id: ObjectId }` -- `ListLayers` -- `GetEntityLayer { id: ObjectId }` - -## Errors (`ApiError`) - -| Variant | Meaning | -|---|---| -| `Validation { op, reason }` | Input rejected before any document mutation. | -| `Geometry { kind, msg }` | The kernel failed (boolean refused, invalid input, ACIS lift/lower). | -| `UnknownId(ObjectId)` | The handle is stale / deleted / never existed (never a panic). | -| `Unsupported(String)` | Capability not supported for this entity or backend. | -| `Transport(String)` | The channel failed (disconnected / oversized / timeout). | - -`ApiError` is serializable, so IPC returns the same structured error the in-process -executor produced. - -## Envelope & transport - -`DocApiEnvelope { version, body }` — `body` is `Op(Operation)` (a single write) or -`Queries(Vec)` (a read batch). bincode-serialized; `version` is the crate's -envelope version (bridges must check it). Transports: `InProcess` (host) and -`OcsPluginApiIpc` (plugin over `ocs_plugin_api`), both behind the `Transport: -Send + Sync` trait (`apply(envelope) -> Receipt`, `alive()`). `Receipt` carries -`outcome` (per-op result), `query_results`, and `new_revision`. diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json deleted file mode 100644 index 04697d696..000000000 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ /dev/null @@ -1,1349 +0,0 @@ -{ - "envelope_version": 1, - "families": [ - { - "acadrust_variant": "Solid3D", - "collection": "solids", - "constructors": [ - { - "args": [ - { - "name": "origin", - "ty": "[f64; 3]" - }, - { - "name": "size", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "primitive": "Cuboid" - }, - "name": "create_cuboid", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "primitive": "Sphere" - }, - "name": "create_sphere", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "base", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - }, - { - "name": "height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "primitive": "Cylinder" - }, - "name": "create_cylinder", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "base", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - }, - { - "name": "height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "primitive": "Cone" - }, - "name": "create_cone", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "major_radius", - "ty": "f64" - }, - { - "name": "minor_radius", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "primitive": "Torus" - }, - "name": "create_torus", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "origin", - "ty": "[f64; 3]" - }, - { - "name": "size", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "primitive": "Wedge" - }, - "name": "create_wedge", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "profile", - "ty": "EntityRef" - }, - { - "name": "direction", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "extrude", - "op": "Extrude", - "returns": "Solid" - }, - { - "args": [ - { - "name": "profile", - "ty": "EntityRef" - }, - { - "name": "pivot", - "ty": "[f64; 3]" - }, - { - "name": "axis", - "ty": "[f64; 3]" - }, - { - "name": "angle", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "revolve", - "op": "Revolve", - "returns": "Solid" - }, - { - "args": [ - { - "name": "profiles", - "ty": "&[EntityRef]" - } - ], - "bulk": false, - "fixed": null, - "name": "loft", - "op": "Loft", - "returns": "Solid" - } - ], - "handle": "Solid", - "methods": [ - { - "args": [ - { - "name": "other", - "ty": "Solid" - } - ], - "bulk": false, - "fixed": { - "erase_sources": true, - "op": "Intersection" - }, - "kind": "op", - "name": "intersect", - "op": "SolidBoolean", - "query": null, - "read_only": false, - "returns": "Solid" - }, - { - "args": [ - { - "name": "other", - "ty": "Solid" - } - ], - "bulk": false, - "fixed": { - "erase_sources": true, - "op": "Union" - }, - "kind": "op", - "name": "union", - "op": "SolidBoolean", - "query": null, - "read_only": false, - "returns": "Solid" - }, - { - "args": [ - { - "name": "other", - "ty": "Solid" - } - ], - "bulk": false, - "fixed": { - "erase_sources": true, - "op": "Difference" - }, - "kind": "op", - "name": "subtract", - "op": "SolidBoolean", - "query": null, - "read_only": false, - "returns": "Solid" - }, - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "volume", - "op": null, - "query": "GetVolume", - "read_only": true, - "returns": "f64" - }, - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "centroid", - "op": null, - "query": "GetCentroid", - "read_only": true, - "returns": "[f64; 3]" - }, - { - "args": [ - { - "name": "other", - "ty": "Solid" - } - ], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "intersects", - "op": null, - "query": "GetIntersects", - "read_only": true, - "returns": "bool" - } - ], - "name": "Solid" - }, - { - "acadrust_variant": "Line", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "start", - "ty": "[f64; 3]" - }, - { - "name": "end", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "curve": "Line" - }, - "name": "create_line", - "op": "CreateCurve", - "returns": "Line" - } - ], - "handle": "Line", - "methods": [], - "name": "Line" - }, - { - "acadrust_variant": "Circle", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "curve": "Circle" - }, - "name": "create_circle", - "op": "CreateCurve", - "returns": "Circle" - } - ], - "handle": "Circle", - "methods": [], - "name": "Circle" - }, - { - "acadrust_variant": "LwPolyline", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "points", - "ty": "&[[f64; 3]]" - }, - { - "name": "closed", - "ty": "bool" - } - ], - "bulk": false, - "fixed": { - "curve": "Polyline" - }, - "name": "create_polyline", - "op": "CreateCurve", - "returns": "Polyline" - } - ], - "handle": "Polyline", - "methods": [ - { - "args": [ - { - "name": "at", - "ty": "usize" - }, - { - "name": "point", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "add_vertex", - "op": "AddVertex", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "Polyline" - }, - { - "acadrust_variant": "Point", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "position", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "curve": "Point" - }, - "name": "create_point", - "op": "CreateCurve", - "returns": "Point" - }, - { - "args": [ - { - "name": "positions", - "ty": "&[[f64; 3]]" - } - ], - "bulk": true, - "fixed": { - "curve": "Point" - }, - "name": "create_points", - "op": "CreateMany", - "returns": "Vec" - } - ], - "handle": "Point", - "methods": [], - "name": "Point" - }, - { - "acadrust_variant": "Arc", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - }, - { - "name": "start_angle", - "ty": "f64" - }, - { - "name": "end_angle", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "curve": "Arc" - }, - "name": "create_arc", - "op": "CreateCurve", - "returns": "ArcCurve" - } - ], - "handle": "ArcCurve", - "methods": [], - "name": "Arc" - }, - { - "acadrust_variant": "Ellipse", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "major_axis", - "ty": "[f64; 3]" - }, - { - "name": "ratio", - "ty": "f64" - }, - { - "name": "start", - "ty": "f64" - }, - { - "name": "end", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "curve": "Ellipse" - }, - "name": "create_ellipse", - "op": "CreateCurve", - "returns": "Ellipse" - } - ], - "handle": "Ellipse", - "methods": [], - "name": "Ellipse" - }, - { - "acadrust_variant": "Spline", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "degree", - "ty": "i32" - }, - { - "name": "control_points", - "ty": "&[[f64; 3]]" - }, - { - "name": "knots", - "ty": "&[f64]" - }, - { - "name": "weights", - "ty": "&[f64]" - } - ], - "bulk": false, - "fixed": { - "curve": "Spline" - }, - "name": "create_spline", - "op": "CreateCurve", - "returns": "Spline" - } - ], - "handle": "Spline", - "methods": [], - "name": "Spline" - }, - { - "acadrust_variant": "Ray", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "origin", - "ty": "[f64; 3]" - }, - { - "name": "direction", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "curve": "Ray" - }, - "name": "create_ray", - "op": "CreateCurve", - "returns": "Ray" - } - ], - "handle": "Ray", - "methods": [], - "name": "Ray" - }, - { - "acadrust_variant": "XLine", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "origin", - "ty": "[f64; 3]" - }, - { - "name": "direction", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "curve": "XLine" - }, - "name": "create_xline", - "op": "CreateCurve", - "returns": "XLine" - } - ], - "handle": "XLine", - "methods": [], - "name": "XLine" - }, - { - "acadrust_variant": "Text", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "value", - "ty": "&str" - }, - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "height", - "ty": "f64" - }, - { - "name": "rotation", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_text", - "op": "CreateText", - "returns": "Text" - } - ], - "handle": "Text", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "content", - "op": null, - "query": "GetTextContent", - "read_only": true, - "returns": "String" - }, - { - "args": [ - { - "name": "value", - "ty": "&str" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_content", - "op": "SetTextContent", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "Text" - }, - { - "acadrust_variant": "MText", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "value", - "ty": "&str" - }, - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_mtext", - "op": "CreateMText", - "returns": "MText" - } - ], - "handle": "MText", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "content", - "op": null, - "query": "GetTextContent", - "read_only": true, - "returns": "String" - }, - { - "args": [ - { - "name": "value", - "ty": "&str" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_content", - "op": "SetTextContent", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "MText" - }, - { - "acadrust_variant": "Hatch", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "boundary", - "ty": "&[[f64; 2]]" - }, - { - "name": "solid", - "ty": "bool" - } - ], - "bulk": false, - "fixed": null, - "name": "create_hatch", - "op": "CreateHatch", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "boundary", - "op": null, - "query": "GetHatchBoundary", - "read_only": true, - "returns": "Vec>" - } - ], - "name": "Hatch" - }, - { - "acadrust_variant": "Dimension", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "first_point", - "ty": "[f64; 3]" - }, - { - "name": "second_point", - "ty": "[f64; 3]" - }, - { - "name": "definition_point", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_linear", - "op": "CreateDimensionLinear", - "returns": "Dimension" - }, - { - "args": [ - { - "name": "center", - "ty": "[f64; 3]" - }, - { - "name": "point", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_radius", - "op": "CreateDimensionRadius", - "returns": "Dimension" - }, - { - "args": [ - { - "name": "center", - "ty": "[f64; 3]" - }, - { - "name": "point", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_diameter", - "op": "CreateDimensionDiameter", - "returns": "Dimension" - }, - { - "args": [ - { - "name": "vertex", - "ty": "[f64; 3]" - }, - { - "name": "first_point", - "ty": "[f64; 3]" - }, - { - "name": "second_point", - "ty": "[f64; 3]" - }, - { - "name": "arc_location", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_angular", - "op": "CreateDimensionAngular", - "returns": "Dimension" - }, - { - "args": [ - { - "name": "vertex", - "ty": "[f64; 3]" - }, - { - "name": "first_point", - "ty": "[f64; 3]" - }, - { - "name": "second_point", - "ty": "[f64; 3]" - }, - { - "name": "arc_location", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_angular2ln", - "op": "CreateDimensionAngular2Ln", - "returns": "Dimension" - } - ], - "handle": "Dimension", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "measurement", - "op": null, - "query": "GetDimensionMeasurement", - "read_only": true, - "returns": "f64" - } - ], - "name": "Dimension" - }, - { - "acadrust_variant": "Insert", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "block_name", - "ty": "&str" - }, - { - "name": "insert_point", - "ty": "[f64; 3]" - }, - { - "name": "scale", - "ty": "f64" - }, - { - "name": "rotation", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_insert", - "op": "CreateInsert", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "attributes", - "op": null, - "query": "GetAttributes", - "read_only": true, - "returns": "Vec<(String, String)>" - }, - { - "args": [ - { - "name": "tag", - "ty": "&str" - }, - { - "name": "value", - "ty": "&str" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_attribute", - "op": "SetAttribute", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "Insert" - }, - { - "acadrust_variant": "AttributeDefinition", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "tag", - "ty": "&str" - }, - { - "name": "prompt", - "ty": "&str" - }, - { - "name": "default_value", - "ty": "&str" - }, - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "height", - "ty": "f64" - }, - { - "name": "rotation", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_attribute_definition", - "op": "CreateAttributeDefinition", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [], - "name": "AttributeDefinition" - }, - { - "acadrust_variant": "Viewport", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "center", - "ty": "[f64; 3]" - }, - { - "name": "width", - "ty": "f64" - }, - { - "name": "height", - "ty": "f64" - }, - { - "name": "view_target", - "ty": "[f64; 3]" - }, - { - "name": "view_height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_viewport", - "op": "CreateViewport", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "viewport_view", - "op": null, - "query": "GetViewportView", - "read_only": true, - "returns": "([f64; 3], f64)" - }, - { - "args": [ - { - "name": "view_target", - "ty": "[f64; 3]" - }, - { - "name": "view_height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_view", - "op": "SetViewportView", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "Viewport" - }, - { - "acadrust_variant": "RasterImage", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "file_path", - "ty": "&str" - }, - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "u_vector", - "ty": "[f64; 3]" - }, - { - "name": "v_vector", - "ty": "[f64; 3]" - }, - { - "name": "size", - "ty": "[f64; 2]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_raster_image", - "op": "CreateRasterImage", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [], - "name": "RasterImage" - }, - { - "acadrust_variant": "Table", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "data", - "ty": "&[Vec]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_table", - "op": "CreateTable", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [], - "name": "Table" - }, - { - "acadrust_variant": "XRecord", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "spec", - "ty": "XRecordSpec" - } - ], - "bulk": false, - "fixed": null, - "name": "create_xrecord", - "op": "CreateXRecord", - "returns": "XRecord" - } - ], - "handle": "XRecord", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "payload", - "op": null, - "query": "GetXRecord", - "read_only": true, - "returns": "XRecordSpec" - }, - { - "args": [ - { - "name": "spec", - "ty": "XRecordSpec" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_payload", - "op": "SetXRecord", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "XRecord" - }, - { - "acadrust_variant": null, - "collection": "layers", - "constructors": [], - "handle": "Layer", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "list", - "op": null, - "query": "ListLayers", - "read_only": true, - "returns": "Vec" - }, - { - "args": [ - { - "name": "info", - "ty": "LayerInfo" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "create", - "op": "CreateLayer", - "query": null, - "read_only": false, - "returns": "()" - }, - { - "args": [ - { - "name": "name", - "ty": "String" - }, - { - "name": "info", - "ty": "LayerInfo" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "update", - "op": "UpdateLayer", - "query": null, - "read_only": false, - "returns": "()" - }, - { - "args": [ - { - "name": "name", - "ty": "String" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "delete", - "op": "DeleteLayer", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "Layer" - } - ], - "layouts": { - "ObjectId": { - "struct": [ - { - "name": "0", - "type": "u64" - } - ] - }, - "Operation": { - "enum": [ - "CreateSolid(SolidPrimitive)", - "CreateCurve(Curve2Spec)", - "Extrude { profile: ObjectId, direction: [f64; 3] }", - "Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }", - "Transform { id: ObjectId, placement: PlacementSpec }", - "SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }", - "AddVertex { id: ObjectId, at: usize, point: [f64; 3] }", - "Delete { id: ObjectId }", - "CreateMany(Vec)", - "TransformMany { ids: Vec, placement: PlacementSpec }", - "DeleteMany(Vec)", - "CreateInsert(InsertSpec)", - "CreateViewport(ViewportSpec)", - "CreateText(TextSpec)", - "CreateMText(MTextSpec)", - "SetTextContent { id: ObjectId, value: String }", - "CreateHatch(HatchSpec)", - "CreateDimensionLinear(DimensionSpec)", - "SetAttribute { id: ObjectId, tag: String, value: String }", - "SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }", - "CreateRasterImage(RasterImageSpec)", - "Loft { profiles: Vec }", - "CreateDimensionRadius(DimensionRadialSpec)", - "CreateDimensionDiameter(DimensionRadialSpec)", - "CreateDimensionAngular(DimensionAngularSpec)", - "CreateAttributeDefinition(AttributeDefinitionSpec)", - "CreateTable(TableSpec)", - "CreateDimensionAngular2Ln(DimensionAngularSpec)", - "SetXData { id: ObjectId, application_name: String, record: Option }", - "CreateXRecord(XRecordSpec)", - "SetXRecord { id: ObjectId, spec: XRecordSpec }", - "CreateLayer(LayerInfo)", - "UpdateLayer { name: String, info: LayerInfo }", - "DeleteLayer { name: String }", - "SetEntityLayer { id: ObjectId, layer: String }", - "SetXDataMany(Vec<(ObjectId, String, Option)" - ] - }, - "Query": { - "enum": [ - "GetEntity { id: ObjectId }", - "GetBounds { id: ObjectId }", - "GetCentroid { id: ObjectId }", - "GetVolume { id: ObjectId }", - "GetIntersects { a: ObjectId, b: ObjectId }", - "GetGeometryRevision", - "GetTextContent { id: ObjectId }", - "GetHatchBoundary { id: ObjectId }", - "GetDimensionMeasurement { id: ObjectId }", - "GetAttributes { id: ObjectId }", - "GetBlockEntities { block_name: String }", - "GetViewportView { id: ObjectId }", - "GetXData { id: ObjectId, application_name: String }", - "GetXRecord { id: ObjectId }", - "ListLayers", - "GetEntityLayer { id: ObjectId }" - ] - } - }, - "object_model": { - "collections": [ - "solids", - "curves", - "entities" - ], - "document": "Document", - "handles": [ - "Entity", - "Solid", - "Line", - "Circle", - "Polyline", - "Point", - "ArcCurve", - "Ellipse", - "Spline", - "Ray", - "XLine", - "Text", - "MText", - "Dimension", - "XRecord", - "Layer" - ], - "root": "DocApi" - }, - "schema": "ocs_doc_api/binding_schema" -} \ No newline at end of file diff --git a/crates/ocs_doc_api/src/gen/layouts.json b/crates/ocs_doc_api/src/gen/layouts.json deleted file mode 100644 index b43755336..000000000 --- a/crates/ocs_doc_api/src/gen/layouts.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "ObjectId": { "struct": [ { "name": "0", "type": "u64" } ] }, - "Operation": { - "enum": [ - "CreateSolid(SolidPrimitive)", - "CreateCurve(Curve2Spec)", - "Extrude { profile: ObjectId, direction: [f64; 3] }", - "Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }", - "Transform { id: ObjectId, placement: PlacementSpec }", - "SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }", - "AddVertex { id: ObjectId, at: usize, point: [f64; 3] }", - "Delete { id: ObjectId }", - "CreateMany(Vec)", - "TransformMany { ids: Vec, placement: PlacementSpec }", - "DeleteMany(Vec)", - "CreateInsert(InsertSpec)", - "CreateViewport(ViewportSpec)", - "CreateText(TextSpec)", - "CreateMText(MTextSpec)", - "SetTextContent { id: ObjectId, value: String }", - "CreateHatch(HatchSpec)", - "CreateDimensionLinear(DimensionSpec)", - "SetAttribute { id: ObjectId, tag: String, value: String }", - "SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }", - "CreateRasterImage(RasterImageSpec)", - "Loft { profiles: Vec }", - "CreateDimensionRadius(DimensionRadialSpec)", - "CreateDimensionDiameter(DimensionRadialSpec)", - "CreateDimensionAngular(DimensionAngularSpec)", - "CreateAttributeDefinition(AttributeDefinitionSpec)", - "CreateTable(TableSpec)", - "CreateDimensionAngular2Ln(DimensionAngularSpec)", - "SetXData { id: ObjectId, application_name: String, record: Option }", - "CreateXRecord(XRecordSpec)", - "SetXRecord { id: ObjectId, spec: XRecordSpec }", - "CreateLayer(LayerInfo)", - "UpdateLayer { name: String, info: LayerInfo }", - "DeleteLayer { name: String }", - "SetEntityLayer { id: ObjectId, layer: String }", - "SetXDataMany(Vec<(ObjectId, String, Option)" - ] - }, - "Query": { - "enum": [ - "GetEntity { id: ObjectId }", - "GetBounds { id: ObjectId }", - "GetCentroid { id: ObjectId }", - "GetVolume { id: ObjectId }", - "GetIntersects { a: ObjectId, b: ObjectId }", - "GetGeometryRevision", - "GetTextContent { id: ObjectId }", - "GetHatchBoundary { id: ObjectId }", - "GetDimensionMeasurement { id: ObjectId }", - "GetAttributes { id: ObjectId }", - "GetBlockEntities { block_name: String }", - "GetViewportView { id: ObjectId }", - "GetXData { id: ObjectId, application_name: String }", - "GetXRecord { id: ObjectId }", - "ListLayers", - "GetEntityLayer { id: ObjectId }" - ] - } -} diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs deleted file mode 100644 index 7d8e91d22..000000000 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ /dev/null @@ -1,127 +0,0 @@ -// HAND-MAINTAINED wire vocabulary (the Operation enum). This file is NOT derived -// from spec/entities.toml by build.rs — the enum is the canonical append-only -// wire vocabulary; the spec's `op` names must each map to a variant here -// (enforced by a test). Append new variants at the END only (bincode discriminant -// stability). Do NOT reorder or remove variants. - -use serde::{Deserialize, Serialize}; - -use crate::id::ObjectId; -use crate::ops::{ - AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec, - DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, - SolidPrimitive, TableSpec, TextSpec, ViewportSpec, XDataRecord, XRecordSpec, -}; - -/// A typed write operation. Each variant is ONE atomic host call = one -/// undo step. Append new variants at the END only. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[non_exhaustive] -pub enum Operation { - // construction - CreateSolid(SolidPrimitive), - CreateCurve(Curve2Spec), - Extrude { - profile: ObjectId, - direction: [f64; 3], - }, - Revolve { - profile: ObjectId, - axis: ([f64; 3], [f64; 3]), - angle: f64, - }, - // modification - Transform { - id: ObjectId, - placement: PlacementSpec, - }, - SolidBoolean { - op: BoolOp, - a: ObjectId, - b: ObjectId, - erase_sources: bool, - }, - AddVertex { - id: ObjectId, - at: usize, - point: [f64; 3], - }, - Delete { - id: ObjectId, - }, - // Bulk ops (single op, upfront-validation all-or-nothing — NOT a transaction, §5.3) - CreateMany(Vec), - TransformMany { - ids: Vec, - placement: PlacementSpec, - }, - DeleteMany(Vec), - // containers - CreateInsert(InsertSpec), - // paper-space - CreateViewport(ViewportSpec), - // annotations - CreateText(TextSpec), - CreateMText(MTextSpec), - SetTextContent { - id: ObjectId, - value: String, - }, - // hatch - CreateHatch(HatchSpec), - // dimensions - CreateDimensionLinear(DimensionSpec), - // attributes - SetAttribute { - id: ObjectId, - tag: String, - value: String, - }, - // viewport view - SetViewportView { - id: ObjectId, - view_target: [f64; 3], - view_height: f64, - }, - // media - CreateRasterImage(RasterImageSpec), - // multi-profile solid - Loft { - profiles: Vec, - }, - // dimension sub-types - CreateDimensionRadius(DimensionRadialSpec), - CreateDimensionDiameter(DimensionRadialSpec), - CreateDimensionAngular(DimensionAngularSpec), - // attribute definitions - CreateAttributeDefinition(AttributeDefinitionSpec), - // tables - CreateTable(TableSpec), - // dimension sub-types (2-line angular) - CreateDimensionAngular2Ln(DimensionAngularSpec), - // xdata / xrecord roundtrip - SetXData { - id: ObjectId, - application_name: String, - record: Option, - }, - SetXDataMany(Vec<(ObjectId, String, Option)>), - CreateXRecord(XRecordSpec), - SetXRecord { - id: ObjectId, - spec: XRecordSpec, - }, - // layer CRUD - CreateLayer(crate::ops::LayerInfo), - UpdateLayer { - name: String, - info: crate::ops::LayerInfo, - }, - DeleteLayer { - name: String, - }, - SetEntityLayer { - id: ObjectId, - layer: String, - }, -} diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs deleted file mode 100644 index f58c6a0c8..000000000 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ /dev/null @@ -1,89 +0,0 @@ -// HAND-MAINTAINED wire vocabulary (the Query enum). NOT derived from the spec by -// build.rs — the enum is the canonical append-only wire vocabulary; the spec's -// `query` names must each map to a variant here (enforced by a test). Append new -// variants at the END only (bincode discriminant stability). - -use serde::{Deserialize, Serialize}; - -use crate::id::ObjectId; - -/// A typed read query. Read-only: no mutation, no undo, no revision -/// bump. Safe to batch (`EnvelopeBody::Queries`). Append new variants at the END only. -/// (Not `Copy`: some variants carry owned data like a block name.) -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[non_exhaustive] -pub enum Query { - GetEntity { - id: ObjectId, - }, - GetBounds { - id: ObjectId, - }, - GetCentroid { - id: ObjectId, - }, - GetVolume { - id: ObjectId, - }, - /// Whether two entities' bounds overlap (two-phase conditional modelling). - GetIntersects { - a: ObjectId, - b: ObjectId, - }, - GetGeometryRevision, - /// The text content of a Text/MText annotation. - GetTextContent { - id: ObjectId, - }, - /// The boundary loops of a Hatch (outer + islands) as polylines. - GetHatchBoundary { - id: ObjectId, - }, - /// The measured value of a Dimension (distance or angle). - GetDimensionMeasurement { - id: ObjectId, - }, - /// An insert's attributes as (tag, value) pairs. - GetAttributes { - id: ObjectId, - }, - /// The entity ids + kinds inside a block definition (read-only traversal). - GetBlockEntities { - block_name: String, - }, - /// A viewport's view (target + zoom height). - GetViewportView { - id: ObjectId, - }, - /// The XDATA record for `application_name` attached to an entity (`None` if absent). - GetXData { - id: ObjectId, - application_name: String, - }, - /// The XRECORD object payload by id. - GetXRecord { - id: ObjectId, - }, - /// List all layers in the document. - ListLayers, - /// The layer name of an entity. - GetEntityLayer { - id: ObjectId, - }, - /// Enumerate first-class entities, optionally filtered by kind and/or layer. - /// `include_bounds` populates `EntityView::bounds`; leave it false for a - /// lightweight list view. - EnumerateEntities { - kind: Option, - layer: Option, - include_bounds: bool, - }, - /// The WCS location of a Point entity. - GetPointPosition { - id: ObjectId, - }, - /// The start and end WCS points of a Line entity. - GetLineGeometry { - id: ObjectId, - }, -} diff --git a/crates/ocs_doc_api/src/geom.rs b/crates/ocs_doc_api/src/geom.rs deleted file mode 100644 index e77086d51..000000000 --- a/crates/ocs_doc_api/src/geom.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! Kernel geometry helpers (host feature, plan §10 code-mapping table). Thin -//! wrappers over `cadkernel::brep::*` mapping the crate's plain-data DTOs to the -//! non-serde kernel types. Kernel keys never cross the API (decision #7). - -use cadkernel::geom2d::Curve as Curve2; -use cadkernel::space::Plane; - -use crate::backend::KernelBody; -use crate::error::{ApiError, ApiResult, GeometryErrorKind}; -use crate::ops::{BoolOp, SolidPrimitive}; - -fn geom_err(kind: GeometryErrorKind, msg: impl Into) -> ApiError { - ApiError::geometry(kind, msg) -} - -/// Signed volume and centroid of a closed triangle mesh via the divergence -/// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source -/// of truth shared by the host backend and the test/example mock backends. -pub fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { - mesh.mass_properties().unwrap_or((0.0, [0.0; 3])) -} - -pub fn validate_placement(p: &crate::PlacementSpec) -> ApiResult<()> { - let placement = cadkernel::brep::Placement { - origin: p.origin, - x_axis: p.x_axis, - y_axis: p.y_axis, - z_axis: p.z_axis, - }; - if placement.scale().is_none() || p.origin.iter().any(|v| !v.is_finite()) { - return Err(ApiError::validation( - "Transform", - "placement must be a finite, nonzero similarity", - )); - } - Ok(()) -} - -/// Construct a solid primitive. -pub fn make_solid(p: &SolidPrimitive) -> ApiResult { - crate::validation::solid(p)?; - use cadkernel::brep::make; - let body = match *p { - SolidPrimitive::Cuboid { origin, size } => make::cuboid(origin, size), - SolidPrimitive::Sphere { centre, radius } => make::sphere(centre, radius), - SolidPrimitive::Cylinder { - base, - radius, - height, - } => make::cylinder(base, radius, height), - SolidPrimitive::Cone { - base, - radius, - height, - } => make::cone(base, radius, height), - SolidPrimitive::Torus { - centre, - major_radius, - minor_radius, - } => make::torus(centre, major_radius, minor_radius), - SolidPrimitive::Wedge { origin, size } => make::wedge(origin, size[0], size[1], size[2]), - }; - body.ok_or_else(|| { - geom_err( - GeometryErrorKind::InvalidInput, - "kernel make returned empty body", - ) - }) -} - -/// Boolean combine (pure): `brep::combine(a, b, op, operation_tolerance(&[&a,&b]))`. -/// Maps the kernel `Snag` to `ApiError::Geometry`. Does NOT mutate the inputs. -pub(crate) fn boolean(a: &KernelBody, b: &KernelBody, op: BoolOp) -> ApiResult { - let kernel_op = crate::executor::kernel_bool_op(op); - let tol = cadkernel::brep::operation_tolerance(&[a, b]); - cadkernel::brep::combine(a.clone(), b.clone(), kernel_op, tol) - .map_err(|e| geom_err(GeometryErrorKind::BooleanFailed, format!("{e:?}"))) -} - -/// Extrude a closed 2D profile into a solid (`brep::extrude`). -pub(crate) fn extrude( - plane: Plane, - profile: &[Curve2], - direction: [f64; 3], -) -> ApiResult { - cadkernel::brep::extrude(plane, profile, direction) - .ok_or_else(|| geom_err(GeometryErrorKind::InvalidInput, "extrude failed")) -} - -/// Revolve a closed 2D profile about an axis into a solid (`brep::revolve`). -pub(crate) fn revolve( - plane: Plane, - profile: &[Curve2], - pivot: [f64; 3], - axis: [f64; 3], - angle: f64, -) -> ApiResult { - cadkernel::brep::revolve(plane, profile, pivot, axis, angle) - .ok_or_else(|| geom_err(GeometryErrorKind::InvalidInput, "revolve failed")) -} diff --git a/crates/ocs_doc_api/src/id.rs b/crates/ocs_doc_api/src/id.rs deleted file mode 100644 index 87f4de167..000000000 --- a/crates/ocs_doc_api/src/id.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Stable entity identity. - -use serde::{Deserialize, Serialize}; - -/// A stable u64 handle to a first-level document entity, decoupled from any -/// internal engine/kernel pointer. Value-compatible with `acadrust::Handle` — -/// kernel keys never cross the API boundary. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(transparent)] -pub struct ObjectId(pub u64); - -impl ObjectId { - /// The null / absent handle (`acadrust::Handle::NULL` value `0`). - pub const NULL: ObjectId = ObjectId(0); - - pub const fn as_u64(self) -> u64 { - self.0 - } - - /// Build from a raw u64 (e.g. an `acadrust::Handle`'s value). - pub const fn from_u64(v: u64) -> Self { - Self(v) - } - - /// Build from an `acadrust::Handle` (host/ipc feature). - #[cfg(any(feature = "host", feature = "ipc"))] - pub fn from_handle(h: acadrust::Handle) -> Self { - Self(h.value()) - } - - /// Convert to an `acadrust::Handle` (host/ipc feature). - #[cfg(any(feature = "host", feature = "ipc"))] - pub fn to_handle(self) -> acadrust::Handle { - acadrust::Handle::new(self.0) - } -} diff --git a/crates/ocs_doc_api/src/layer.rs b/crates/ocs_doc_api/src/layer.rs deleted file mode 100644 index 48987f24f..000000000 --- a/crates/ocs_doc_api/src/layer.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Shared layer-name normalization rules. -//! -//! Layer names in OpenCADStudio/acadrust are case-insensitive, leading/trailing -//! whitespace is ignored, and empty names are invalid. The host (`normalize_name` -//! in `acadrust::tables`) implements the canonical rule set; this module re-exports -//! a compatible, dependency-free function for the wire side and for backends that -//! do not link against `acadrust` directly. - -/// Normalize a raw layer name into its case-insensitive lookup key. -/// -/// This mirrors `acadrust::tables::normalize_name` used by the host: -/// - trims leading and trailing ASCII whitespace -/// - converts the result to uppercase -/// - returns `""` if the name is empty after trimming -pub fn normalize_layer_name(name: &str) -> String { - name.trim().to_ascii_uppercase() -} - -/// A normalized layer name is non-empty and matches the host lookup key. -pub fn is_valid_layer_name(name: &str) -> bool { - !normalize_layer_name(name).is_empty() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalization_trims_and_uppercases() { - assert_eq!(normalize_layer_name(" Walls "), "WALLS"); - assert_eq!(normalize_layer_name("walls"), "WALLS"); - assert_eq!(normalize_layer_name("WALLS"), "WALLS"); - } - - #[test] - fn empty_and_whitespace_names_are_invalid() { - assert_eq!(normalize_layer_name(""), ""); - assert_eq!(normalize_layer_name(" "), ""); - assert!(!is_valid_layer_name("")); - assert!(!is_valid_layer_name(" ")); - } -} diff --git a/crates/ocs_doc_api/src/lib.rs b/crates/ocs_doc_api/src/lib.rs deleted file mode 100644 index 649f4bc84..000000000 --- a/crates/ocs_doc_api/src/lib.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Typed document operations, queries and facades over in-process or IPC transports. - -pub mod error; -pub mod id; -pub mod revision; - -#[cfg(feature = "host")] -pub mod backend; -#[cfg(feature = "host")] -pub mod convert; -#[cfg(feature = "host")] -pub mod executor; -#[cfg(feature = "host")] -pub mod geom; -#[cfg(feature = "host")] -mod validation; - -pub mod envelope; -pub mod facade; -pub mod layer; -pub mod ops; -pub mod query; -pub mod transport; - -// Append-only wire enums, maintained alongside the DTOs and spec. -pub mod gen { - pub mod ops { - include!("gen/ops_gen.rs"); - } - pub mod query { - include!("gen/query_gen.rs"); - } - pub use ops::Operation; - pub use query::Query; -} - -pub use error::{ApiError, ApiResult, GeometryErrorKind}; -pub use id::ObjectId; -pub use revision::GeometryRevision; - -pub use envelope::{DocApiEnvelope, EnvelopeBody, OpOutcome, Receipt}; -pub use facade::{ - ArcCurve, Circle, CurveCollection, Dimension, DocApi, Document, Ellipse, Entity, - EntityCollection, HasId, Line, MText, OpGroup, Point, Polyline, QueryBatch, QueryResults, Ray, - Solid, SolidCollection, Spline, Text, XLine, XRecord, -}; -pub use layer::{is_valid_layer_name, normalize_layer_name}; -pub use ops::{ - BoolOp, Color, Curve2Spec, EntitySpec, LayerFlags, LayerInfo, LineWeight, Operation, - PlacementSpec, SolidPrimitive, XDataRecord, XDataValue, XRecordCloningFlags, XRecordEntry, - XRecordSpec, XRecordValue, -}; -pub use query::{Aabb, EntityView, Query, QueryResult}; - -#[cfg(feature = "host")] -pub use convert::{ - bulge_arc_segment, curve_spec_to_entity, entity_bounds, entity_kind_name, - entity_to_profile_curves, transform_entity_geometry, -}; -#[cfg(feature = "doc_api_host")] -pub use transport::ipc::doc_api_for_host; - -pub use transport::Transport; - -/// The crate's own wire-envelope protocol version (`DocApiEnvelope::version`). -/// Bump when the envelope layout or `Operation`/`Query` discriminants change. -/// Bridges must check this before speaking (see `bindings/README.md`). -pub const ENVELOPE_VERSION: u16 = 1; - -/// The generated binding handover schema: object model + method -/// signatures + op/query mapping with curated wire vocabulary. Single, -/// self-contained, versioned with [`ENVELOPE_VERSION`]. -pub fn binding_schema_json() -> &'static str { - include_str!("gen/binding_schema.json") -} diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs deleted file mode 100644 index 7941b4112..000000000 --- a/crates/ocs_doc_api/src/ops.rs +++ /dev/null @@ -1,489 +0,0 @@ -//! Write operations — the typed, transport-agnostic vocabulary for changing the -//! document. The `Operation` enum is **hand-maintained** (append-only) -//! in `src/gen/ops_gen.rs` — it is NOT derived from the spec by build.rs; the -//! spec's `op` names must each map to a variant here (asserted by a test). This -//! module re-exports it and holds the hand-written payload mirrors. - -use serde::{Deserialize, Serialize}; - -pub use crate::gen::Operation; - -/// An XDATA record payload: one registered application name plus its typed -/// value list. This is a plain-data, serde-compatible mirror of -/// `acadrust::xdata::ExtendedDataRecord` so the wire DTO stays independent of -/// the host dependency. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct XDataRecord { - pub application_name: String, - pub values: Vec, -} - -/// A single XDATA value. Mirrors `acadrust::xdata::XDataValue`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum XDataValue { - String(String), - ControlString(String), - LayerName(String), - BinaryData(Vec), - Handle(u64), - Point3D([f64; 3]), - Position3D([f64; 3]), - Displacement3D([f64; 3]), - Direction3D([f64; 3]), - Real(f64), - Distance(f64), - ScaleFactor(f64), - Integer16(i16), - Integer32(i32), -} - -/// Construction spec for an `XRECORD` object. Mirrors the acadrust -/// `XRecord` payload (name + cloning flags + group-code entries) in a -/// serde-compatible, host-independent form. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct XRecordSpec { - pub name: String, - pub cloning_flags: XRecordCloningFlags, - pub entries: Vec, -} - -/// XRecord cloning behavior flags. Mirror of -/// `acadrust::objects::DictionaryCloningFlags`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub enum XRecordCloningFlags { - #[default] - NotApplicable, - KeepExisting, - UseClone, - XrefName, - Name, - UnmangleName, -} - -/// One XRECORD group-code entry. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct XRecordEntry { - pub code: i32, - pub value: XRecordValue, -} - -/// A single XRECORD value. Mirrors `acadrust::objects::XRecordValue`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum XRecordValue { - String(String), - Double(f64), - Int16(i16), - Int32(i32), - Int64(i64), - Byte(u8), - Bool(bool), - Handle(u64), - Point3D([f64; 3]), - Chunk(Vec), -} - -/// Boolean operation kind for [`Operation::SolidBoolean`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum BoolOp { - Union, - Intersection, - Difference, -} - -/// A plain-data color value. Mirrors `acadrust::types::Color` for wire -/// independence from the host dependency. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum Color { - ByLayer, - None, - ByBlock, - Index(u8), - Rgb { r: u8, g: u8, b: u8 }, -} - -impl Color { - pub const WHITE: Color = Color::Index(7); -} - -/// A plain-data line weight. Mirrors `acadrust::types::LineWeight`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub enum LineWeight { - #[default] - ByLayer, - ByBlock, - Default, - /// Specific line weight in 1/100 mm. - Value(i16), -} - -/// Layer visibility/lock flags. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct LayerFlags { - pub frozen: bool, - pub locked: bool, - pub frozen_in_new_viewport: bool, - pub off: bool, -} - -/// A plain-data layer table entry. Mirrors `acadrust::tables::Layer`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct LayerInfo { - pub name: String, - pub flags: LayerFlags, - pub color: Color, - pub line_type: String, - pub line_weight: LineWeight, - pub plot_style: String, - pub is_plottable: bool, -} - -impl LayerInfo { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - flags: LayerFlags::default(), - color: Color::WHITE, - line_type: "Continuous".to_string(), - line_weight: LineWeight::Default, - plot_style: String::new(), - is_plottable: true, - } - } - - /// The normalized (trimmed + uppercased) lookup key for this layer name. - pub fn normalized_name(&self) -> String { - crate::layer::normalize_layer_name(&self.name) - } -} - -/// Construction spec for a B-rep solid primitive (plain-data mirror of the -/// non-serde `cadkernel::brep::make::*` arguments). -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub enum SolidPrimitive { - Cuboid { - origin: [f64; 3], - size: [f64; 3], - }, - Sphere { - centre: [f64; 3], - radius: f64, - }, - Cylinder { - base: [f64; 3], - radius: f64, - height: f64, - }, - Cone { - base: [f64; 3], - radius: f64, - height: f64, - }, - Torus { - centre: [f64; 3], - major_radius: f64, - minor_radius: f64, - }, - Wedge { - origin: [f64; 3], - size: [f64; 3], - }, -} - -/// Construction spec for a minimal 2D curve entity (phase-1 set; profiles/inputs). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum Curve2Spec { - Line { - start: [f64; 3], - end: [f64; 3], - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - Circle { - centre: [f64; 3], - radius: f64, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - Polyline { - points: Vec<[f64; 3]>, - closed: bool, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - Point { - position: [f64; 3], - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// Circular arc, counter-clockwise from `start_angle` to `end_angle` (radians). - Arc { - centre: [f64; 3], - radius: f64, - start_angle: f64, - end_angle: f64, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// Ellipse: `major_axis` is the major-axis endpoint relative to `centre`; - /// `ratio` = minor/major. `start`/`end` parameters 0..2π (full ellipse = 0..2π). - Ellipse { - centre: [f64; 3], - major_axis: [f64; 3], - ratio: f64, - start: f64, - end: f64, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// NURBS spline (control points + knots + weights; `degree` typically 3). - Spline { - degree: i32, - control_points: Vec<[f64; 3]>, - knots: Vec, - weights: Vec, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// A ray from `origin` along `direction` (bounded at origin only). - Ray { - origin: [f64; 3], - direction: [f64; 3], - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// An infinite construction line through `origin` along `direction`. - XLine { - origin: [f64; 3], - direction: [f64; 3], - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, -} - -impl Curve2Spec { - /// The target layer name, if any. - pub fn layer(&self) -> Option<&str> { - match self { - Curve2Spec::Line { layer, .. } - | Curve2Spec::Circle { layer, .. } - | Curve2Spec::Polyline { layer, .. } - | Curve2Spec::Point { layer, .. } - | Curve2Spec::Arc { layer, .. } - | Curve2Spec::Ellipse { layer, .. } - | Curve2Spec::Spline { layer, .. } - | Curve2Spec::Ray { layer, .. } - | Curve2Spec::XLine { layer, .. } => layer.as_deref(), - } - } -} - -/// A generic entity-construction payload used by the bulk op -/// [`crate::ops::Operation::CreateMany`]. Mirrors the per-family construction -/// specs so one bulk op can carry many homogeneous (or mixed) creations. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum EntitySpec { - Curve(Curve2Spec), - Solid(SolidPrimitive), -} - -impl EntitySpec { - /// The target layer name for this spec, if explicitly set. - pub fn layer(&self) -> Option<&str> { - match self { - EntitySpec::Curve(c) => c.layer(), - EntitySpec::Solid(_) => None, - } - } -} - -/// Construction spec for a block reference (`INSERT`): place the block -/// `block_name` (must exist as a BlockRecord) at `insert_point` with uniform -/// `scale` and `rotation` (radians, about Z). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct InsertSpec { - pub block_name: String, - pub insert_point: [f64; 3], - pub scale: f64, - pub rotation: f64, -} - -/// Construction spec for a paper-space `VIEWPORT`: a `width`×`height` viewport -/// centered at `center` (paper space), looking at `view_target` (model space, -/// WCS) with `view_height` (model-space height visible; the viewport's zoom). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ViewportSpec { - pub center: [f64; 3], - pub width: f64, - pub height: f64, - pub view_target: [f64; 3], - pub view_height: f64, -} - -/// Construction spec for a single-line `TEXT` annotation. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TextSpec { - pub value: String, - pub insertion_point: [f64; 3], - pub height: f64, - pub rotation: f64, -} - -/// Construction spec for a multi-line `MTEXT` annotation. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct MTextSpec { - pub value: String, - pub insertion_point: [f64; 3], - pub height: f64, -} - -/// Construction spec for a `HATCH` with a single closed polyline boundary -/// (straight segments). `solid` selects solid fill vs a pattern (pattern naming -/// is a later refinement). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct HatchSpec { - /// Closed boundary as an ordered polyline (first==last implied; closed). - pub boundary: Vec<[f64; 2]>, - pub solid: bool, -} - -/// Construction spec for a linear `DIMENSION`: the two measured points and the -/// dimension-line definition point. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DimensionSpec { - pub first_point: [f64; 3], - pub second_point: [f64; 3], - pub definition_point: [f64; 3], -} - -/// Construction spec for a radial/diameter `DIMENSION`: the circle center and a -/// point on the circle (radius) or a chord point (diameter). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DimensionRadialSpec { - /// Center of the arc/circle (radius) or first chord point (diameter). - pub center: [f64; 3], - /// A point on the arc/circle (radius) or the far chord point (diameter). - pub point: [f64; 3], -} - -/// Construction spec for a 3-point angular `DIMENSION`: vertex + one point on -/// each leg, plus the dimension-arc location. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DimensionAngularSpec { - pub vertex: [f64; 3], - pub first_point: [f64; 3], - pub second_point: [f64; 3], - pub arc_location: [f64; 3], -} - -/// Construction spec for an `ATTDEF` (attribute definition): the tag, prompt, -/// and default value for an in-block attribute, at an insertion point. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AttributeDefinitionSpec { - pub tag: String, - pub prompt: String, - pub default_value: String, - pub insertion_point: [f64; 3], - pub height: f64, - pub rotation: f64, -} - -/// Construction spec for a `TABLE`: an insertion point plus a grid of cell -/// strings (`data[row][column]`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TableSpec { - pub insertion_point: [f64; 3], - /// Grid of cell text values, rows × columns. - pub data: Vec>, -} - -/// Construction spec for a `RASTER_IMAGE`: an image file placed at -/// `insertion_point` with per-pixel world-unit vectors `u_vector`/`v_vector` and -/// pixel `size` (width, height). The host auto-registers the ImageDefinition. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RasterImageSpec { - pub file_path: String, - pub insertion_point: [f64; 3], - pub u_vector: [f64; 3], - pub v_vector: [f64; 3], - /// Pixel size (width, height). - pub size: [f64; 2], -} - -/// A rigid similarity (rotation + translation + optional uniform scale/reflection) -/// as a plain-data mirror of `cadkernel::brep::transform`'s `Placement` -/// (non-serde). Columns are the new basis axes; `origin` is the translation. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct PlacementSpec { - pub x_axis: [f64; 3], - pub y_axis: [f64; 3], - pub z_axis: [f64; 3], - pub origin: [f64; 3], -} - -impl PlacementSpec { - /// Identity placement (no transform). - pub const IDENTITY: PlacementSpec = PlacementSpec { - x_axis: [1.0, 0.0, 0.0], - y_axis: [0.0, 1.0, 0.0], - z_axis: [0.0, 0.0, 1.0], - origin: [0.0, 0.0, 0.0], - }; - - /// Pure translation to `origin` (axes unchanged). - pub fn at(origin: [f64; 3]) -> Self { - Self { - origin, - ..Self::IDENTITY - } - } -} - -/// The item-count cap for a single bulk op envelope: bounds -/// host-side execution time and payload. Over-cap → `ApiError::Validation`. -pub const BULK_ITEM_CAP: usize = 100_000; - -/// Convenience accessors used by the facade/executor. -impl crate::gen::Operation { - /// The op name used in `ApiError::Validation { op, .. }`. - pub fn op_name(&self) -> &'static str { - use crate::gen::Operation::*; - match self { - CreateSolid(_) => "CreateSolid", - CreateCurve(_) => "CreateCurve", - Extrude { .. } => "Extrude", - Revolve { .. } => "Revolve", - Transform { .. } => "Transform", - SolidBoolean { .. } => "SolidBoolean", - AddVertex { .. } => "AddVertex", - Delete { .. } => "Delete", - CreateMany(_) => "CreateMany", - TransformMany { .. } => "TransformMany", - DeleteMany(_) => "DeleteMany", - CreateInsert(_) => "CreateInsert", - CreateViewport(_) => "CreateViewport", - CreateText(_) => "CreateText", - CreateMText(_) => "CreateMText", - SetTextContent { .. } => "SetTextContent", - CreateHatch(_) => "CreateHatch", - CreateDimensionLinear(_) => "CreateDimensionLinear", - SetAttribute { .. } => "SetAttribute", - SetViewportView { .. } => "SetViewportView", - CreateRasterImage(_) => "CreateRasterImage", - Loft { .. } => "Loft", - CreateDimensionRadius(_) => "CreateDimensionRadius", - CreateDimensionDiameter(_) => "CreateDimensionDiameter", - CreateDimensionAngular(_) => "CreateDimensionAngular", - CreateAttributeDefinition(_) => "CreateAttributeDefinition", - CreateTable(_) => "CreateTable", - CreateDimensionAngular2Ln(_) => "CreateDimensionAngular2Ln", - SetXData { .. } => "SetXData", - SetXDataMany(_) => "SetXDataMany", - CreateXRecord(_) => "CreateXRecord", - SetXRecord { .. } => "SetXRecord", - CreateLayer(_) => "CreateLayer", - UpdateLayer { .. } => "UpdateLayer", - DeleteLayer { .. } => "DeleteLayer", - SetEntityLayer { .. } => "SetEntityLayer", - } - } -} diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs deleted file mode 100644 index e53d2b650..000000000 --- a/crates/ocs_doc_api/src/query.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! Read queries — typed, transport-agnostic reads of entity state. -//! The `Query` enum is **hand-maintained** (append-only) in `src/gen/query_gen.rs` -//! — NOT derived from the spec by build.rs (the spec's `query` names must map to -//! a variant here, asserted by a test). This module re-exports it and holds the -//! hand-written result DTOs. - -use serde::{Deserialize, Serialize}; - -use crate::id::ObjectId; -use crate::revision::GeometryRevision; - -pub use crate::gen::Query; - -/// Axis-aligned bounding box (plain-data mirror of the kernel's non-serde `Aabb`). -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct Aabb { - pub min: [f64; 3], - pub max: [f64; 3], -} - -impl Aabb { - #[cfg(feature = "host")] - pub fn overlaps(&self, other: &Aabb) -> bool { - cadkernel::brep::Aabb { - min: self.min, - max: self.max, - } - .overlaps(&cadkernel::brep::Aabb { - min: other.min, - max: other.max, - }) - } -} - -/// A generic, untyped view of an entity returned by [`Query::GetEntity`]. -/// Family-specific data lives in the typed construction specs; this carries -/// identity + kind + a coarse bounding box so any first-level entity is readable. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct EntityView { - pub id: ObjectId, - /// The acadrust `EntityType` variant name (e.g. "Solid3D", "Line"). - pub kind: String, - pub bounds: Option, - /// The normalized (uppercase) layer name the entity is on. - pub layer: String, -} - -/// The result of a single [`Query`] (one per query in a `Queries` batch). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum QueryResult { - Entity(EntityView), - Bounds(Aabb), - Centroid([f64; 3]), - Volume(f64), - /// Whether two entities' bounds overlap (two-phase conditional modelling). - Intersects(bool), - Revision(GeometryRevision), - /// The text content of a Text/MText annotation. - TextContent(String), - /// A hatch's boundary loops (outer + islands), each a closed polyline. - HatchBoundary(Vec>), - /// A dimension's measured value (distance for linear/radius, degrees for angular). - DimensionMeasurement(f64), - /// An insert's attributes as (tag, value) pairs. - Attributes(Vec<(String, String)>), - /// The entities inside a block definition (read-only traversal). - BlockEntities(Vec), - /// A viewport's view: target point (WCS) + zoom height. - ViewportView { - target: [f64; 3], - height: f64, - }, - /// An XDATA record for a single application (`None` if no such record). - XData(Option), - /// An XRECORD object payload. - XRecord(crate::ops::XRecordSpec), - /// All layers in the document. - Layers(Vec), - /// The layer name of an entity. - EntityLayer(String), - /// A filtered list of entity views. - Entities(Vec), - /// The WCS location of a Point entity. - PointPosition([f64; 3]), - /// The start and end WCS points of a Line entity. - LineGeometry(([f64; 3], [f64; 3])), -} - -/// Convenience: the query name for diagnostics. -impl crate::gen::Query { - pub fn query_name(&self) -> &'static str { - use crate::gen::Query::*; - match self { - GetEntity { .. } => "GetEntity", - GetBounds { .. } => "GetBounds", - GetCentroid { .. } => "GetCentroid", - GetVolume { .. } => "GetVolume", - GetIntersects { .. } => "GetIntersects", - GetGeometryRevision => "GetGeometryRevision", - GetTextContent { .. } => "GetTextContent", - GetHatchBoundary { .. } => "GetHatchBoundary", - GetDimensionMeasurement { .. } => "GetDimensionMeasurement", - GetAttributes { .. } => "GetAttributes", - GetBlockEntities { .. } => "GetBlockEntities", - GetViewportView { .. } => "GetViewportView", - GetXData { .. } => "GetXData", - GetXRecord { .. } => "GetXRecord", - ListLayers => "ListLayers", - GetEntityLayer { .. } => "GetEntityLayer", - EnumerateEntities { .. } => "EnumerateEntities", - GetPointPosition { .. } => "GetPointPosition", - GetLineGeometry { .. } => "GetLineGeometry", - } - } -} diff --git a/crates/ocs_doc_api/src/revision.rs b/crates/ocs_doc_api/src/revision.rs deleted file mode 100644 index f0759afe9..000000000 --- a/crates/ocs_doc_api/src/revision.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Geometry revision counter (mirrors the host scene's `geometry_epoch`). - -use serde::{Deserialize, Serialize}; - -/// Monotonic revision of a document's geometry. Bumped exactly once per applied -/// write op (`push_undo` + `finalize_op`). Pure queries return the current -/// revision without bumping it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(transparent)] -pub struct GeometryRevision(pub u64); - -impl GeometryRevision { - pub const fn as_u64(self) -> u64 { - self.0 - } -} diff --git a/crates/ocs_doc_api/src/transport/inproc.rs b/crates/ocs_doc_api/src/transport/inproc.rs deleted file mode 100644 index 8d9353024..000000000 --- a/crates/ocs_doc_api/src/transport/inproc.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! In-process transport: drives a `DocApiBackend` directly, inline. -//! Used by in-tree/built-in plugins and host tests. Thread-safe via a mutex so -//! many handles across many threads share it (calls are serialized). - -use std::sync::Mutex; - -use crate::backend::DocApiBackend; -use crate::envelope::{DocApiEnvelope, EnvelopeBody, Receipt}; -use crate::error::ApiResult; -use crate::executor; -use crate::transport::Transport; - -/// In-process transport: holds the backend behind a mutex (reentrant `&self`). -pub struct InProcess { - backend: Mutex, -} - -impl InProcess { - pub fn new(backend: B) -> Self { - Self { - backend: Mutex::new(backend), - } - } - /// Borrow the backend immutably (test/introspection helper). - pub fn backend(&self) -> std::sync::MutexGuard<'_, B> { - self.backend.lock().expect("in-process backend poisoned") - } -} - -impl Transport for InProcess { - fn apply(&self, req: DocApiEnvelope) -> ApiResult { - req.validate_version()?; - let mut backend = self - .backend - .lock() - .map_err(|_| crate::error::ApiError::Transport("in-process backend poisoned".into()))?; - match req.body { - EnvelopeBody::Op(op) => executor::apply_op(&mut *backend, op), - EnvelopeBody::Queries(queries) => executor::apply_queries(&mut *backend, queries), - } - } -} diff --git a/crates/ocs_doc_api/src/transport/ipc.rs b/crates/ocs_doc_api/src/transport/ipc.rs deleted file mode 100644 index f9b4b1d3e..000000000 --- a/crates/ocs_doc_api/src/transport/ipc.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! IPC transport: serializes `DocApiEnvelope` into the -//! append-only `PluginRequest::DocApiRequest { tab_id, bytes }` variant and ships -//! it through the plugin's `PluginRequestSender`. The host routes that variant to -//! the same crate executor (`ocs_doc_api::executor`) — one implementation. - -use std::sync::Arc; - -use ocs_plugin_api::host::{HostApi, PluginRequestError, PluginRequestSender}; -use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; - -use crate::envelope::{DocApiEnvelope, Receipt}; -use crate::error::{ApiError, ApiResult}; -use crate::facade::DocApi; -use crate::transport::Transport; - -/// Out-of-process IPC transport over the `ocs_plugin_api` channel. `Send + Sync`; -/// the underlying `PluginRequestSender` already serializes + correlates (V4). -pub struct OcsPluginApiIpc { - sender: Arc, - tab_id: u64, -} - -impl OcsPluginApiIpc { - pub fn new(sender: Arc, tab_id: u64) -> Self { - Self { sender, tab_id } - } - - fn transport_err(e: PluginRequestError) -> ApiError { - ApiError::Transport(e.0) - } -} - -/// Build a typed [`DocApi`] from any host that can provide a -/// [`PluginRequestSender`] (out-of-process / V4 worker plugins). -/// -/// Returns `None` for hosts that do not expose a request sender (e.g. in-process -/// hosts). In-process code should use [`crate::DocApi::in_process`] with a -/// concrete [`crate::backend::DocApiBackend`]. -pub fn doc_api_for_host(host: &dyn HostApi) -> Option { - let sender = host.plugin_request_sender()?; - let tab_id = host.tab_id(); - Some(DocApi::connect( - Arc::new(OcsPluginApiIpc::new(sender.into(), tab_id)), - tab_id, - )) -} - -impl Transport for OcsPluginApiIpc { - fn apply(&self, req: DocApiEnvelope) -> ApiResult { - let bytes = bincode::serialize(&req) - .map_err(|e| ApiError::Transport(format!("envelope serialize: {e}")))?; - let resp = self - .sender - .request(PluginRequest::DocApiRequest { - tab_id: self.tab_id, - bytes, - }) - .map_err(Self::transport_err)?; - match resp { - PluginResponse::DocApiResponse { bytes } => { - // The host serializes `ApiResult` so op/query errors - // (validation, geometry, unknown id) surface as the SAME - // structured `ApiError` the in-process executor produced. - let result: ApiResult = bincode::deserialize(&bytes) - .map_err(|e| ApiError::Transport(format!("receipt deserialize: {e}")))?; - result - } - PluginResponse::Error(msg) => Err(ApiError::Transport(msg)), - other => Err(ApiError::Transport(format!( - "unexpected DocApi response variant: {other:?}" - ))), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::facade::HasId; - use crate::ObjectId; - use ocs_plugin_api::host::{ - CadDocument, EntityType, ExtendedDataRecord, Handle, HostApi, - }; - use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; - use std::any::Any; - use std::sync::{Arc, Mutex}; - - #[derive(Clone)] - struct MockSender { - captured: Arc>>, - } - - impl PluginRequestSender for MockSender { - fn request(&self, req: PluginRequest) -> Result { - let tab_id = match &req { - PluginRequest::DocApiRequest { tab_id, .. } => *tab_id, - _ => 0, - }; - self.captured.lock().unwrap().push((tab_id, req)); - Ok(PluginResponse::DocApiResponse { - bytes: bincode::serialize(&crate::ApiResult::Ok(Receipt { - outcome: Some(crate::envelope::OpOutcome::NewId(ObjectId::from_u64(7))), - query_results: vec![], - new_revision: crate::revision::GeometryRevision(1), - })) - .unwrap(), - }) - } - } - - struct MockHost { - sender: Option, - } - - impl HostApi for MockHost { - fn tab_index(&self) -> usize { - 0 - } - fn document(&self) -> &CadDocument { - unimplemented!() - } - fn document_mut(&mut self) -> &mut CadDocument { - unimplemented!() - } - fn add_entity(&mut self, _entity: EntityType) -> Handle { - unimplemented!() - } - fn bump_geometry(&mut self) {} - fn push_undo(&mut self, _label: &str) {} - fn set_dirty(&mut self) {} - fn push_info(&mut self, _msg: &str) {} - fn push_output(&mut self, _msg: &str) {} - fn push_error(&mut self, _msg: &str) {} - fn start_interactive(&mut self, _command: Box) {} - fn read_record(&self, _handle: Handle, _app_name: &str) -> Option<&ExtendedDataRecord> { - None - } - fn write_record(&mut self, _handle: Handle, _record: ExtendedDataRecord) -> bool { - false - } - fn remove_record(&mut self, _handle: Handle, _app_name: &str) -> bool { - false - } - fn plugin_state_any(&self, _plugin_id: &str) -> Option<&(dyn Any + Send + Sync)> { - None - } - fn plugin_state_any_mut(&mut self, _plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)> { - None - } - fn ensure_plugin_state_any( - &mut self, - _plugin_id: &'static str, - _init: &mut dyn FnMut() -> Box, - ) -> &mut (dyn Any + Send + Sync) { - unimplemented!() - } - fn document_reader(&self) -> Box { - unimplemented!() - } - fn plugin_request_sender(&self) -> Option> { - self.sender.as_ref().map(|s| Box::new(s.clone()) as Box) - } - } - - #[test] - fn doc_api_for_host_builds_doc_api_and_routes_doc_api_request() { - let captured = Arc::new(Mutex::new(Vec::new())); - let host = MockHost { - sender: Some(MockSender { - captured: Arc::clone(&captured), - }), - }; - let doc_api = doc_api_for_host(&host).expect("host exposes a sender"); - let doc = doc_api.document(host.tab_id()); - let id = doc - .solids() - .create_cuboid([0.0; 3], [1.0; 3]) - .expect("mock returns a NewId receipt"); - assert_eq!(id.id(), ObjectId::from_u64(7)); - - let calls = captured.lock().unwrap(); - assert_eq!(calls.len(), 1); - let (tab_id, ref req) = &calls[0]; - assert_eq!(*tab_id, host.tab_id()); - match req { - PluginRequest::DocApiRequest { tab_id, bytes } => { - assert_eq!(*tab_id, host.tab_id()); - assert!(!bytes.is_empty()); - } - other => panic!("expected DocApiRequest, got {other:?}"), - } - } - - #[test] - fn doc_api_for_host_returns_none_without_sender() { - let host = MockHost { sender: None }; - assert!(doc_api_for_host(&host).is_none()); - } -} diff --git a/crates/ocs_doc_api/src/transport/mod.rs b/crates/ocs_doc_api/src/transport/mod.rs deleted file mode 100644 index 3a17f92e4..000000000 --- a/crates/ocs_doc_api/src/transport/mod.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! The transport boundary — the ONLY plugin↔host boundary. -//! -//! Portability contract (SPSC / flume / shared-memory safe): a transport impl -//! must (a) accept `&self` calls from many threads and serialize sends internally, -//! (b) demultiplex responses to the right caller by a correlation id, (c) -//! block-with-timeout, (d) expose liveness. This matches the existing V4 channel -//! shape, so swapping socket → shm changes only the impl, never the facade. - -#[cfg(feature = "host")] -pub mod inproc; -#[cfg(feature = "ipc")] -pub mod ipc; - -#[cfg(feature = "host")] -pub use inproc::InProcess; -#[cfg(feature = "ipc")] -pub use ipc::OcsPluginApiIpc; - -use crate::envelope::{DocApiEnvelope, Receipt}; -use crate::error::ApiResult; - -/// The only plugin↔host boundary. Object-centric: one method per unit of work — -/// a single write op, or a query batch. `&self` + `Send + Sync` so many entity -/// handles across many threads share one transport concurrently. -pub trait Transport: Send + Sync { - /// Apply `req` (ONE write op, or a query batch) and return its `Receipt`. - /// A write request carries exactly one `Operation`; a read request carries - /// one-or-more `Query` items (read-only batching is safe). Blocks up to an - /// internal timeout. Reentrant: safe to call concurrently from many threads. - fn apply(&self, req: DocApiEnvelope) -> ApiResult; - - /// Cheap host-liveness probe (shm has no socket-disconnect to detect a dead peer). - fn alive(&self) -> bool { - true - } -} diff --git a/crates/ocs_doc_api/src/validation.rs b/crates/ocs_doc_api/src/validation.rs deleted file mode 100644 index dc9ca9909..000000000 --- a/crates/ocs_doc_api/src/validation.rs +++ /dev/null @@ -1,299 +0,0 @@ -//! Reject invalid numeric inputs before the backend starts an undo operation. -use crate::{id::ObjectId, ops::*, ApiError, ApiResult}; - -fn finite(values: &[f64]) -> bool { - values.iter().all(|v| v.is_finite()) -} -fn positive(value: f64) -> bool { - value.is_finite() && value > 0.0 -} -fn direction(value: &[f64; 3]) -> bool { - finite(value) && value.iter().any(|v| *v != 0.0) -} -fn check(valid: bool, op: &'static str) -> ApiResult<()> { - if valid { - Ok(()) - } else { - Err(ApiError::validation( - op, - "invalid or nonfinite geometry input", - )) - } -} - -fn check_payload(valid: bool, op: &'static str, reason: &'static str) -> ApiResult<()> { - if valid { - Ok(()) - } else { - Err(ApiError::validation(op, reason)) - } -} - -fn valid_application_name(name: &str) -> bool { - !name.trim().is_empty() && !name.contains('\0') -} - -fn valid_xdata_record(app: &str, record: Option<&XDataRecord>) -> bool { - valid_application_name(app) - && record.is_none_or(|record| { - record.application_name == app - && record.values.iter().all(|value| match value { - XDataValue::String(value) - | XDataValue::ControlString(value) - | XDataValue::LayerName(value) => !value.contains('\0'), - XDataValue::Point3D(value) - | XDataValue::Position3D(value) - | XDataValue::Displacement3D(value) - | XDataValue::Direction3D(value) => finite(value), - XDataValue::Real(value) - | XDataValue::Distance(value) - | XDataValue::ScaleFactor(value) => value.is_finite(), - XDataValue::BinaryData(_) - | XDataValue::Handle(_) - | XDataValue::Integer16(_) - | XDataValue::Integer32(_) => true, - }) - }) -} - -fn valid_xrecord(spec: &XRecordSpec) -> bool { - !spec.name.trim().is_empty() - && !spec.name.contains('\0') - && spec.entries.len() <= BULK_ITEM_CAP - && spec.entries.iter().all(|entry| { - let code = entry.code; - match &entry.value { - XRecordValue::String(value) => { - matches!( - code, - 0..=4 - | 6..=9 - | 100..=102 - | 300..=309 - | 410..=419 - | 430..=439 - | 470..=479 - | 999 - | 1000..=1003 - ) && !value.contains('\0') - && value.encode_utf16().count() <= u16::MAX as usize - } - XRecordValue::Point3D(value) => { - matches!(code, 10..=37 | 110..=139 | 210..=269 | 1010..=1039 | 1043..=1069) - && finite(value) - } - XRecordValue::Double(value) => { - matches!(code, 38..=59 | 140..=149 | 460..=469 | 1040..=1042) - && value.is_finite() - } - XRecordValue::Int16(_) => { - matches!(code, 60..=79 | 170..=179 | 270..=279 | 370..=389 | 400..=409 | 1070) - } - XRecordValue::Int32(_) => matches!(code, 80..=99 | 420..=429 | 440..=459 | 1071), - XRecordValue::Int64(_) => matches!(code, 150..=169), - XRecordValue::Byte(_) => matches!(code, 280..=289), - XRecordValue::Bool(_) => matches!(code, 290..=299), - XRecordValue::Handle(_) => { - code < 0 || matches!(code, 5 | 105 | 320..=369 | 390..=399 | 480..=481 | 1005) - } - XRecordValue::Chunk(value) => { - matches!(code, 310..=319 | 1004) && value.len() <= u8::MAX as usize - } - } - }) -} - -pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> { - use Curve2Spec::*; - let valid = match spec { - Line { start, end, .. } => finite(start) && finite(end) && start != end, - Circle { centre, radius, .. } => finite(centre) && positive(*radius), - Point { position, .. } => finite(position), - Arc { - centre, - radius, - start_angle, - end_angle, - .. - } => finite(centre) && positive(*radius) && finite(&[*start_angle, *end_angle]), - Polyline { points, closed, .. } => { - points.len() >= if *closed { 3 } else { 2 } - && points.len() <= BULK_ITEM_CAP - && points.iter().all(|p| finite(p) && p[2] == points[0][2]) - } - Ellipse { - centre, - major_axis, - ratio, - start, - end, - .. - } => { - finite(centre) - && direction(major_axis) - && positive(*ratio) - && *ratio <= 1.0 - && finite(&[*start, *end]) - } - Spline { - degree, - control_points, - knots, - weights, - .. - } => { - *degree > 0 - && control_points.len() <= BULK_ITEM_CAP - && knots.len() <= BULK_ITEM_CAP - && weights.len() <= BULK_ITEM_CAP - && cadkernel::space::NurbsCurve3::new_strict( - *degree as usize, - control_points.clone(), - knots.clone(), - if weights.is_empty() { - vec![1.0; control_points.len()] - } else { - weights.clone() - }, - ) - .is_some() - } - Ray { - origin, - direction: d, - .. - } - | XLine { - origin, - direction: d, - .. - } => finite(origin) && direction(d), - }; - check(valid, "CreateCurve") -} - -pub(crate) fn operation(op: &Operation) -> ApiResult<()> { - use Operation::*; - let valid = match op { - CreateCurve(spec) => return curve(spec), - Extrude { direction: d, .. } => direction(d), - Revolve { - axis: (pivot, d), - angle, - .. - } => finite(pivot) && direction(d) && angle.is_finite() && *angle != 0.0, - AddVertex { point, .. } => finite(point), - CreateInsert(s) => { - finite(&s.insert_point) - && s.scale.is_finite() - && s.scale != 0.0 - && s.rotation.is_finite() - } - CreateViewport(s) => { - finite(&s.center) - && finite(&s.view_target) - && positive(s.width) - && positive(s.height) - && positive(s.view_height) - } - CreateText(s) => finite(&s.insertion_point) && positive(s.height) && s.rotation.is_finite(), - CreateMText(s) => finite(&s.insertion_point) && positive(s.height), - SetXData { - id, - application_name, - record, - } => { - return check_payload( - *id != ObjectId::NULL && valid_xdata_record(application_name, record.as_ref()), - "SetXData", - "invalid XDATA record", - ) - } - SetXDataMany(records) => { - return check_payload( - records.iter().all(|(id, app, record)| { - *id != ObjectId::NULL && valid_xdata_record(app, record.as_ref()) - }), - "SetXDataMany", - "invalid XDATA record", - ) - } - CreateXRecord(spec) => { - return check_payload(valid_xrecord(spec), "CreateXRecord", "invalid XRecord payload") - } - SetXRecord { id, spec } => { - return check_payload( - *id != ObjectId::NULL && valid_xrecord(spec), - "SetXRecord", - "invalid XRecord payload", - ) - } - CreateHatch(s) => { - (3..=BULK_ITEM_CAP).contains(&s.boundary.len()) && s.boundary.iter().all(|p| finite(p)) - } - CreateDimensionLinear(s) => { - finite(&s.first_point) && finite(&s.second_point) && finite(&s.definition_point) - } - CreateDimensionRadius(s) | CreateDimensionDiameter(s) => { - finite(&s.center) && finite(&s.point) - } - CreateDimensionAngular(s) | CreateDimensionAngular2Ln(s) => { - finite(&s.vertex) - && finite(&s.first_point) - && finite(&s.second_point) - && finite(&s.arc_location) - } - CreateAttributeDefinition(s) => { - finite(&s.insertion_point) && positive(s.height) && s.rotation.is_finite() - } - SetViewportView { - view_target, - view_height, - .. - } => finite(view_target) && positive(*view_height), - CreateRasterImage(s) => { - finite(&s.insertion_point) - && direction(&s.u_vector) - && direction(&s.v_vector) - && s.size.iter().all(|v| positive(*v)) - } - CreateTable(s) => { - finite(&s.insertion_point) - && !s.data.is_empty() - && !s.data[0].is_empty() - && s.data - .len() - .checked_mul(s.data[0].len()) - .is_some_and(|n| n <= BULK_ITEM_CAP) - && s.data.iter().all(|r| r.len() == s.data[0].len()) - } - _ => true, - }; - check(valid, op.op_name()) -} - -pub(crate) fn solid(spec: &SolidPrimitive) -> ApiResult<()> { - use SolidPrimitive::*; - let valid = match spec { - Cuboid { origin, size } | Wedge { origin, size } => { - finite(origin) && size.iter().all(|v| positive(*v)) - } - Sphere { centre, radius } => finite(centre) && positive(*radius), - Cylinder { - base, - radius, - height, - } - | Cone { - base, - radius, - height, - } => finite(base) && positive(*radius) && positive(*height), - Torus { - centre, - major_radius, - minor_radius, - } => finite(centre) && positive(*major_radius) && positive(*minor_radius), - }; - check(valid, "CreateSolid") -} diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs deleted file mode 100644 index d9059bbde..000000000 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ /dev/null @@ -1,1082 +0,0 @@ -//! Unit tests over an in-memory mock `DocApiBackend`: no host. -//! Covers the §9 examples: per-op atomicity, boolean erase_sources, OpGroup -//! compensation, bulk all-or-nothing, revision bumps, read-only query batching, -//! and stale-handle `UnknownId` semantics. - -use std::collections::HashMap; -use std::sync::Arc; - -use ocs_doc_api::backend::{DocApiBackend, KernelBody}; -use ocs_doc_api::{ - normalize_layer_name, Aabb, ApiError, ApiResult, Color, Curve2Spec, DocApi, EntityView, - GeometryErrorKind, GeometryRevision, HasId, LayerInfo, LineWeight, ObjectId, PlacementSpec, - XDataRecord, XDataValue, -}; - -/// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds and -/// the example mock — all three must answer curve bounds identically). -fn curve_bounds(spec: &Curve2Spec) -> Aabb { - match spec { - Curve2Spec::Line { start, end, .. } => Aabb { - min: [ - start[0].min(end[0]), - start[1].min(end[1]), - start[2].min(end[2]), - ], - max: [ - start[0].max(end[0]), - start[1].max(end[1]), - start[2].max(end[2]), - ], - }, - Curve2Spec::Circle { centre, radius, .. } - | Curve2Spec::Arc { centre, radius, .. } => Aabb { - min: [centre[0] - radius, centre[1] - radius, centre[2]], - max: [centre[0] + radius, centre[1] + radius, centre[2]], - }, - Curve2Spec::Point { position, .. } => Aabb { - min: *position, - max: *position, - }, - Curve2Spec::Polyline { points, .. } - | Curve2Spec::Spline { - control_points: points, - .. - } => { - let mut min = [f64::INFINITY; 3]; - let mut max = [f64::NEG_INFINITY; 3]; - for p in points { - for i in 0..3 { - min[i] = min[i].min(p[i]); - max[i] = max[i].max(p[i]); - } - } - Aabb { min, max } - } - Curve2Spec::Ellipse { - centre, major_axis, .. - } => { - let r = (major_axis[0] * major_axis[0] - + major_axis[1] * major_axis[1] - + major_axis[2] * major_axis[2]) - .sqrt(); - Aabb { - min: [centre[0] - r, centre[1] - r, centre[2] - r], - max: [centre[0] + r, centre[1] + r, centre[2] + r], - } - } - Curve2Spec::Ray { origin, .. } | Curve2Spec::XLine { origin, .. } => Aabb { - min: *origin, - max: *origin, - }, - } -} - -/// In-memory mock backend: HashMap + a tiny entity table. -/// Tracks undo steps + revision bumps to assert per-op semantics. -#[derive(Clone)] -struct MockBackend { - next_id: u64, - revision: u64, - undo_steps: u32, - bodies: HashMap, - kinds: HashMap, - /// Layer name assigned to each entity. - entity_layers: HashMap, - /// Stored layer table. - layers: HashMap, - /// Stored text content for Text/MText annotations. - text_values: HashMap, - /// Stored hatch boundary loops. - hatch_boundaries: HashMap>>, - /// Stored dimension measurements. - dimension_measurements: HashMap, - /// Stored insert attributes. - attributes_store: HashMap>, - /// Stored 2D curve specs so `bounds()` works on curve entities (aligned with - /// the example mock — both mocks must answer curve bounds the same way). - curves: HashMap, - /// Stored viewport views (target, height). - viewport_views: HashMap, - /// Stored XDATA records keyed by (entity id, application name). - xdata_store: HashMap<(ObjectId, String), ocs_doc_api::XDataRecord>, - /// Stored XRECORD payloads keyed by entity id. - xrecord_store: HashMap, -} - -impl Default for MockBackend { - fn default() -> Self { - let mut backend = Self { - next_id: 0, - revision: 0, - undo_steps: 0, - bodies: HashMap::new(), - kinds: HashMap::new(), - entity_layers: HashMap::new(), - layers: HashMap::new(), - text_values: HashMap::new(), - hatch_boundaries: HashMap::new(), - dimension_measurements: HashMap::new(), - attributes_store: HashMap::new(), - curves: HashMap::new(), - viewport_views: HashMap::new(), - xdata_store: HashMap::new(), - xrecord_store: HashMap::new(), - }; - backend - .layers - .insert("0".to_string(), ocs_doc_api::LayerInfo::new("0")); - backend - } -} - -impl MockBackend { - fn alloc(&mut self, kind: &str) -> ObjectId { - self.next_id += 1; - let id = ObjectId::from_u64(self.next_id); - self.kinds.insert(id, kind.to_string()); - id - } - fn body(&self, id: ObjectId) -> ApiResult<&KernelBody> { - self.bodies.get(&id).ok_or(ApiError::UnknownId(id)) - } - fn add_curve_only(&mut self, spec: &Curve2Spec) -> ApiResult { - let kind = match spec { - Curve2Spec::Line { .. } => "Line", - Curve2Spec::Circle { .. } => "Circle", - Curve2Spec::Polyline { .. } => "LwPolyline", - Curve2Spec::Point { .. } => "Point", - Curve2Spec::Arc { .. } => "Arc", - Curve2Spec::Ellipse { .. } => "Ellipse", - Curve2Spec::Spline { .. } => "Spline", - Curve2Spec::Ray { .. } => "Ray", - Curve2Spec::XLine { .. } => "XLine", - }; - let id = self.alloc(kind); - self.curves.insert(id, spec.clone()); - Ok(id) - } -} - -impl DocApiBackend for MockBackend { - fn resolve_body(&mut self, id: ObjectId) -> ApiResult { - self.bodies.get(&id).cloned().ok_or(ApiError::UnknownId(id)) - } - fn store_solid(&mut self, body: &KernelBody) -> ApiResult { - let id = self.alloc("Solid3D"); - self.bodies.insert(id, body.clone()); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> { - if self.bodies.insert(id, body.clone()).is_none() { - return Err(ApiError::UnknownId(id)); - } - Ok(()) - } - fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { - // Validate per-spec layers up front so the op is atomic. - for spec in specs { - if let Some(name) = spec.layer() { - self.lookup_layer(name).map_err(|e| ApiError::validation( - "CreateMany", - e.to_string(), - ))?; - } - } - let mut next = self.clone(); - let ids = specs - .iter() - .map(|spec| match spec { - ocs_doc_api::EntitySpec::Solid(spec) => { - next.store_solid(&ocs_doc_api::geom::make_solid(spec)?) - } - ocs_doc_api::EntitySpec::Curve(spec) => { - ocs_doc_api::convert::curve_spec_to_entity(spec)?; - next.add_curve(spec) - } - }) - .collect::>>()?; - next.push_undo("CreateMany"); - next.finalize_op(); - *self = next; - Ok(ids) - } - fn transform_many(&mut self, ids: &[ObjectId], placement: &PlacementSpec) -> ApiResult<()> { - let mut next = self.clone(); - for &id in ids { - next.transform_entity(id, placement)?; - } - next.push_undo("TransformMany"); - next.finalize_op(); - *self = next; - Ok(()) - } - fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - let layer = spec - .layer() - .map(|name| { - self.lookup_layer(name).map(|info| info.normalized_name()) - }) - .transpose()?; - let id = self.add_curve_only(spec)?; - self.entity_layers - .insert(id, layer.unwrap_or_else(|| "0".to_string())); - Ok(id) - } - - fn lookup_layer(&self, name: &str) -> ApiResult { - let norm = normalize_layer_name(name); - if norm.is_empty() { - return Err(ApiError::validation("LookupLayer", "empty layer name")); - } - self.layers - .get(&norm) - .cloned() - .ok_or_else(|| ApiError::validation("LookupLayer", format!("layer '{name}' does not exist"))) - } - fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { - if spec.block_name.is_empty() { - return Err(ApiError::validation("CreateInsert", "empty block name")); - } - let id = self.alloc("Insert"); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { - let id = self.alloc("Viewport"); - self.viewport_views - .insert(id, (spec.view_target, spec.view_height)); - Ok(id) - } - fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { - let id = self.alloc("Text"); - self.text_values.insert(id, spec.value.clone()); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult { - let id = self.alloc("MText"); - self.text_values.insert(id, spec.value.clone()); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { - if let std::collections::hash_map::Entry::Occupied(mut e) = self.text_values.entry(id) { - e.insert(value.to_string()); - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - fn text_content(&self, id: ObjectId) -> ApiResult { - self.text_values - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult { - let id = self.alloc("Hatch"); - self.hatch_boundaries - .insert(id, vec![spec.boundary.clone()]); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { - self.hatch_boundaries - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn add_dimension_linear( - &mut self, - spec: &ocs_doc_api::ops::DimensionSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - let d = ((spec.first_point[0] - spec.second_point[0]).powi(2) - + (spec.first_point[1] - spec.second_point[1]).powi(2) - + (spec.first_point[2] - spec.second_point[2]).powi(2)) - .sqrt(); - self.dimension_measurements.insert(id, d); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn add_dimension_radius( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - let d = ((spec.center[0] - spec.point[0]).powi(2) - + (spec.center[1] - spec.point[1]).powi(2) - + (spec.center[2] - spec.point[2]).powi(2)) - .sqrt(); - self.dimension_measurements.insert(id, d); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn add_dimension_diameter( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - let id = self.add_dimension_radius(spec)?; - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn add_dimension_angular( - &mut self, - _spec: &ocs_doc_api::ops::DimensionAngularSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - self.dimension_measurements.insert(id, 90.0); // mock: 90 degrees - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn dimension_measurement(&self, id: ObjectId) -> ApiResult { - self.dimension_measurements - .get(&id) - .copied() - .ok_or(ApiError::UnknownId(id)) - } - fn add_attribute_definition( - &mut self, - spec: &ocs_doc_api::ops::AttributeDefinitionSpec, - ) -> ApiResult { - if spec.tag.is_empty() { - return Err(ApiError::validation( - "CreateAttributeDefinition", - "empty tag", - )); - } - Ok(self.alloc("AttributeDefinition")) - } - fn add_dimension_angular2ln( - &mut self, - _spec: &ocs_doc_api::ops::DimensionAngularSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - self.dimension_measurements.insert(id, 90.0); // mock: 90 degrees - Ok(id) - } - fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { - let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); - if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) { - return Err(ApiError::validation( - "CreateTable", - "table needs a non-empty rectangular grid", - )); - } - let id = self.alloc("Table"); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let attrs = self.attributes_store.entry(id).or_default(); - if let Some(a) = attrs.iter_mut().find(|(t, _)| t == tag) { - a.1 = value.to_string(); - } else { - attrs.push((tag.to_string(), value.to_string())); - } - Ok(()) - } - fn attributes(&self, id: ObjectId) -> ApiResult> { - self.attributes_store - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn block_entities(&self, _block_name: &str) -> ApiResult> { - Ok(Vec::new()) - } - fn add_raster_image( - &mut self, - _spec: &ocs_doc_api::ops::RasterImageSpec, - ) -> ApiResult { - let id = self.alloc("RasterImage"); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn loft( - &mut self, - sections: &[(cadkernel::space::Plane, Vec)], - ) -> ApiResult { - if sections.len() < 2 { - return Err(ApiError::validation("Loft", "loft needs >= 2 profiles")); - } - // Mock: store a unit cuboid as the loft result. - let body = cadkernel::brep::make::cuboid([0.0, 0.0, 0.0], [1.0; 3]) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "make cuboid"))?; - self.store_solid(&body) - } - fn set_viewport_view( - &mut self, - id: ObjectId, - view_target: [f64; 3], - view_height: f64, - ) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - self.viewport_views.insert(id, (view_target, view_height)); - Ok(()) - } - fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> { - self.viewport_views - .get(&id) - .copied() - .ok_or(ApiError::UnknownId(id)) - } - fn set_xdata( - &mut self, - id: ObjectId, - application_name: &str, - record: Option<&ocs_doc_api::XDataRecord>, - ) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let key = (id, application_name.to_string()); - match record { - Some(r) => { - self.xdata_store.insert(key, r.clone()); - } - None => { - self.xdata_store.remove(&key); - } - } - Ok(()) - } - fn xdata( - &self, - id: ObjectId, - application_name: &str, - ) -> ApiResult> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - Ok(self - .xdata_store - .get(&(id, application_name.to_string())) - .cloned()) - } - fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult { - let id = self.alloc("XRecord"); - self.xrecord_store.insert(id, spec.clone()); - Ok(id) - } - fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "SetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - self.xrecord_store.insert(id, spec.clone()); - Ok(()) - } - fn xrecord(&self, id: ObjectId) -> ApiResult> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "GetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - Ok(self.xrecord_store.get(&id).cloned()) - } - fn create_layer(&mut self, info: &ocs_doc_api::LayerInfo) -> ApiResult<()> { - let name = normalize_layer_name(&info.name); - if name.is_empty() { - return Err(ApiError::validation("CreateLayer", "empty layer name")); - } - if self.layers.contains_key(&name) { - return Err(ApiError::validation( - "CreateLayer", - format!("layer '{name}' already exists"), - )); - } - let mut stored = info.clone(); - stored.name = name.clone(); - self.layers.insert(name, stored); - Ok(()) - } - fn update_layer(&mut self, name: &str, info: &ocs_doc_api::LayerInfo) -> ApiResult<()> { - let name_upper = normalize_layer_name(name); - if !self.layers.contains_key(&name_upper) { - return Err(ApiError::validation( - "UpdateLayer", - format!("layer '{name}' does not exist"), - )); - } - let mut stored = info.clone(); - stored.name = name_upper.clone(); - self.layers.insert(name_upper, stored); - Ok(()) - } - fn delete_layer(&mut self, name: &str) -> ApiResult<()> { - let name_upper = normalize_layer_name(name); - if name_upper == "0" { - return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0")); - } - if self - .entity_layers - .values() - .any(|l| normalize_layer_name(l) == name_upper) - { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name_upper}' still in use"), - )); - } - if self.layers.remove(&name_upper).is_none() { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name}' does not exist"), - )); - } - Ok(()) - } - fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let layer_upper = normalize_layer_name(layer); - let target = self - .layers - .get(&layer_upper) - .ok_or_else(|| { - ApiError::validation( - "SetEntityLayer", - format!("layer '{layer_upper}' does not exist"), - ) - })?; - if target.flags.locked { - return Err(ApiError::Unsupported(format!( - "entity {id:?} cannot be moved to locked layer '{layer_upper}'" - ))); - } - self.entity_layers.insert(id, layer_upper); - Ok(()) - } - fn layers(&self) -> ApiResult> { - let mut v: Vec<_> = self.layers.values().cloned().collect(); - v.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(v) - } - fn entity_layer(&self, id: ObjectId) -> ApiResult { - self.entity_layers - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn enumerate_entities( - &self, - kind: Option<&str>, - layer: Option<&str>, - include_bounds: bool, - ) -> ApiResult> { - let mut out = Vec::new(); - for (id, kind_name) in &self.kinds { - if let Some(k) = kind { - if !k.eq_ignore_ascii_case(kind_name) { - continue; - } - } - let entity_layer = self - .entity_layers - .get(id) - .cloned() - .unwrap_or_else(|| "0".to_string()); - if let Some(l) = layer { - if !l.eq_ignore_ascii_case(&entity_layer) { - continue; - } - } - let bounds = if include_bounds { - self.curves.get(id).map(curve_bounds) - } else { - None - }; - out.push(EntityView { - id: *id, - kind: kind_name.clone(), - layer: entity_layer, - bounds, - }); - } - Ok(out) - } - fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> { - let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; - match spec { - Curve2Spec::Point { position, .. } => Ok(*position), - _ => Err(ApiError::Unsupported( - "GetPointPosition is only for Point entities".into(), - )), - } - } - fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> { - let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; - match spec { - Curve2Spec::Line { start, end, .. } => Ok((*start, *end)), - _ => Err(ApiError::Unsupported( - "GetLineGeometry is only for Line entities".into(), - )), - } - } - fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { - if self.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - fn remove_entity(&mut self, id: ObjectId) -> ApiResult { - self.bodies.remove(&id); - self.xdata_store.retain(|(k, _), _| *k != id); - self.xrecord_store.remove(&id); - self.entity_layers.remove(&id); - Ok(self.kinds.remove(&id).is_some()) - } - fn get_entity(&mut self, id: ObjectId) -> ApiResult { - let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone(); - let bounds = self.bodies.get(&id).and_then(|b| { - cadkernel::brep::body_bounds(b).map(|bb| Aabb { - min: bb.min, - max: bb.max, - }) - }); - let layer = self - .entity_layers - .get(&id) - .cloned() - .unwrap_or_else(|| "0".to_string()); - Ok(EntityView { - id, - kind, - layer, - bounds, - }) - } - fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - if let Some(body) = self.bodies.get(&id).cloned() { - let place = cadkernel::brep::Placement { - x_axis: placement.x_axis, - y_axis: placement.y_axis, - z_axis: placement.z_axis, - origin: placement.origin, - }; - let moved = cadkernel::brep::transform(&body, &place).ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed") - })?; - self.bodies.insert(id, moved); - } - Ok(()) - } - fn profile_curves(&self, _id: ObjectId) -> ApiResult> { - Err(ApiError::Unsupported("profiles not mocked".into())) - } - fn bounds(&mut self, id: ObjectId) -> ApiResult { - // Solids via the kernel body; 2D curves from their stored spec (aligned - // with the example mock — both mocks answer curve bounds identically). - if let Some(spec) = self.curves.get(&id) { - return Ok(curve_bounds(spec)); - } - let body = self.body(id)?; - let bb = cadkernel::brep::body_bounds(body) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?; - Ok(Aabb { - min: bb.min, - max: bb.max, - }) - } - fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { - let body = self.body(id)?; - Ok(ocs_doc_api::geom::mesh_volume_centroid(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).1) - } - fn volume(&mut self, id: ObjectId) -> ApiResult { - let body = self.body(id)?; - Ok(ocs_doc_api::geom::mesh_volume_centroid(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).0) - } - fn entity_exists(&self, id: ObjectId) -> bool { - self.kinds.contains_key(&id) - } - fn object_exists(&self, id: ObjectId) -> bool { - self.xrecord_store.contains_key(&id) - } - fn revision(&self) -> GeometryRevision { - GeometryRevision(self.revision) - } - fn push_undo(&mut self, _label: &str) {} - fn finalize_op(&mut self) { - self.undo_steps += 1; - self.revision += 1; - } -} - -fn api() -> (DocApi, Arc>) { - let tp = Arc::new(ocs_doc_api::transport::InProcess::new( - MockBackend::default(), - )); - (DocApi::connect(tp.clone(), 0), tp) -} - -#[test] -fn cuboid_intersect_cuboid_atomic_ops_and_revision() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let mut grp = ocs_doc_api::OpGroup::new(); - - let rev0 = doc.revision().unwrap(); - // Two overlapping boxes — the kernel's own boolean test case (boolean.rs pair()). - let block = grp - .track( - doc.solids() - .create_cuboid([0.0, 0.0, 0.0], [10.0, 10.0, 10.0]), - ) - .unwrap(); - let other = grp - .track( - doc.solids() - .create_cuboid([5.0, 5.0, 5.0], [10.0, 10.0, 10.0]), - ) - .unwrap(); - assert_eq!(doc.revision().unwrap().as_u64(), rev0.as_u64() + 2); - - let lens = match block.intersect(&other) { - Ok(l) => { - grp.commit(); - l - } - Err(e) => panic!("intersect failed: {e}"), - }; - assert_eq!(doc.revision().unwrap().as_u64(), rev0.as_u64() + 3); - - let res = doc - .query_batch(|q| { - q.bounds(&lens); - q.volume(&lens); - }) - .unwrap(); - let bb = res.bounds(0).unwrap(); - let vol = res.volume(1).unwrap(); - // Intersection of [0,10]³ ∩ [5,15]³ = [5,10]³ → bounds + volume 125. - assert!( - (bb.min[0] - 5.0).abs() < 1e-6 && (bb.max[0] - 10.0).abs() < 1e-6, - "bounds {bb:?}" - ); - assert!(vol > 0.0, "intersection volume must be positive"); -} - -#[test] -fn boolean_erase_sources_removes_second_solid() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let a = doc - .solids() - .create_cuboid([0.0, 0.0, 0.0], [10.0; 3]) - .unwrap(); - let b = doc - .solids() - .create_cuboid([5.0, 5.0, 5.0], [10.0; 3]) - .unwrap(); - let result = a.union(&b).unwrap(); - let backend = tp.backend(); - assert!(backend.entity_exists(a.id())); - assert!(!backend.entity_exists(b.id())); - assert_eq!(result.id(), a.id()); -} - -#[test] -fn stale_handle_get_returns_error() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let ghost = ObjectId::from_u64(999_999); - assert!(doc.entities().get(ghost).is_err()); -} - -#[test] -fn opgroup_compensate_deletes_created() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let mut grp = ocs_doc_api::OpGroup::new(); - let a = grp - .track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])) - .unwrap(); - let b = grp - .track(doc.solids().create_sphere([5.0, 5.0, 5.0], 4.0)) - .unwrap(); - grp.compensate(&doc).unwrap(); - let backend = tp.backend(); - assert!(!backend.entity_exists(a.id())); - assert!(!backend.entity_exists(b.id())); -} - -#[test] -fn bulk_create_many_one_undo_step() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let rev0 = doc.revision().unwrap().as_u64(); - let coords: Vec<[f64; 3]> = (0..1000).map(|i| [i as f64, 0.0, 0.0]).collect(); - let pts = doc.curves().create_points(&coords).unwrap(); - assert_eq!(pts.len(), 1000); - let backend = tp.backend(); - assert_eq!(backend.revision, rev0 + 1); - assert_eq!(backend.undo_steps, 1); -} - -#[test] -fn transform_many_with_stale_id_fails_and_applies_nothing() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let a = doc - .solids() - .create_cuboid([0.0, 0.0, 0.0], [10.0; 3]) - .unwrap(); - let rev_before = doc.revision().unwrap().as_u64(); - let stale = ObjectId::from_u64(424_242); - let err = doc - .entities() - .transform_many(&[a.id(), stale], PlacementSpec::at([1.0, 0.0, 0.0])) - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); - let backend = tp.backend(); - assert_eq!(backend.revision, rev_before); -} - -#[test] -fn assert_revision_guard() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let rev = doc.revision().unwrap(); - doc.assert_revision(rev).unwrap(); - doc.solids() - .create_cuboid([0.0, 0.0, 0.0], [1.0; 3]) - .unwrap(); - assert!(doc.assert_revision(rev).is_err()); -} - -#[test] -fn failed_boolean_leaves_inputs_live_and_no_undo() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let a = doc - .solids() - .create_cuboid([0.0, 0.0, 0.0], [10.0; 3]) - .unwrap(); - // `b` is stale: boolean fails before push_undo -> no mutation, no undo step. - let stale_solid = { - // Build a Solid handle to a non-existent id via Entity downcast path. - // Here we just call intersect against a deleted id. - let tmp = doc.solids().create_sphere([5.0, 5.0, 5.0], 4.0).unwrap(); - tmp.delete().unwrap(); - tmp - }; - let rev_before = doc.revision().unwrap().as_u64(); - assert!(a.intersect(&stale_solid).is_err()); - let backend = tp.backend(); - // `a` is still live; no undo recorded for the failed boolean. - assert!(backend.entity_exists(a.id())); - assert_eq!(backend.revision, rev_before); -} - -#[test] -fn typed_handles_reject_foreign_sessions_and_tabs() { - let first = DocApi::in_process(MockBackend::default(), 10); - let second = DocApi::in_process(MockBackend::default(), 10); - let doc = first.document(10); - let foreign = second - .document(10) - .curves() - .create_circle([0.0; 3], 2.0) - .unwrap(); - assert!(doc.solids().extrude(&foreign, [0.0, 0.0, 2.0]).is_err()); - assert!(doc - .solids() - .loft(&[foreign.clone(), foreign.clone()]) - .is_err()); - assert!(doc.query_batch(|q| q.bounds(&foreign)).is_err()); - let mut group = ocs_doc_api::OpGroup::new(); - group.track(Ok(foreign)).unwrap(); - assert!(group.compensate(&doc).is_err()); - assert!(first.document(11).curves().create_point([0.0; 3]).is_err()); -} - -#[test] -fn doc_api_layer_crud_via_facade_and_entity_assignment() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - - let mut info = LayerInfo::new("Walls"); - info.color = Color::Index(1); - info.line_weight = LineWeight::Value(25); - doc.create_layer(&info).unwrap(); - - let layers = doc.layers().unwrap(); - assert!(layers.iter().any(|l| l.name == "WALLS")); - - let mut updated = info.clone(); - updated.color = Color::Index(2); - doc.update_layer("walls", &updated).unwrap(); - - let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); - line.set_layer("Walls").unwrap(); - assert_eq!(line.layer().unwrap(), "WALLS"); - - let err = doc.delete_layer("Walls").unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); - - let other = LayerInfo::new("Other"); - doc.create_layer(&other).unwrap(); - line.set_layer("Other").unwrap(); - doc.delete_layer("Walls").unwrap(); - assert!(!doc.layers().unwrap().iter().any(|l| l.name == "WALLS")); -} - -#[test] -fn set_xdata_many_is_atomic_and_assigns_records() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); - let circle = doc.curves().create_circle([0.0; 3], 1.0).unwrap(); - - let record1 = XDataRecord { - application_name: "APP1".to_string(), - values: vec![XDataValue::Integer32(42)], - }; - let record2 = XDataRecord { - application_name: "APP2".to_string(), - values: vec![XDataValue::Real(3.14)], - }; - doc.set_xdata_many([ - (line.id(), "APP1", Some(record1.clone())), - (circle.id(), "APP2", Some(record2.clone())), - ]) - .unwrap(); - - let r1 = doc.entities().get(line.id()).unwrap().xdata("APP1").unwrap().unwrap(); - assert_eq!(r1.values, vec![XDataValue::Integer32(42)]); - let r2 = doc.entities().get(circle.id()).unwrap().xdata("APP2").unwrap().unwrap(); - assert_eq!(r2.values, vec![XDataValue::Real(3.14)]); -} - -#[test] -fn set_xdata_many_with_unknown_id_fails_atomically() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); - let stale = ObjectId::from_u64(999_999); - - let record = XDataRecord { - application_name: "APP1".to_string(), - values: vec![XDataValue::Integer32(42)], - }; - let err = doc - .set_xdata_many([ - (line.id(), "APP1", Some(record.clone())), - (stale, "APP2", Some(record)), - ]) - .unwrap_err(); - assert!(matches!(err, ApiError::UnknownId(..) | ApiError::Validation { .. })); - assert!(doc.entities().get(line.id()).unwrap().xdata("APP1").unwrap().is_none()); -} - -#[test] -fn xdata_rejects_a_mismatched_record_name() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); - let entity = doc.entities().get(line.id()).unwrap(); - let result = entity.set_xdata( - "EXPECTED", - Some(XDataRecord { - application_name: "OTHER".into(), - values: vec![], - }), - ); - assert!(matches!(result, Err(ApiError::Validation { .. }))); - assert!(entity.xdata("EXPECTED").unwrap().is_none()); -} - -#[test] -fn create_xrecord_returns_the_typed_handle() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let spec = ocs_doc_api::XRecordSpec { - name: "PLUGIN_DATA".into(), - cloning_flags: ocs_doc_api::XRecordCloningFlags::NotApplicable, - entries: vec![ocs_doc_api::XRecordEntry { - code: 1, - value: ocs_doc_api::XRecordValue::String("value".into()), - }], - }; - let record = doc.entities().create_xrecord(&spec).unwrap(); - assert_eq!(record.payload().unwrap(), spec); -} - -#[test] -fn create_many_applies_per_curve_layer() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - doc.create_layer(&LayerInfo::new("Walls")).unwrap(); - - let ids = doc - .entities() - .create_many(&[ - ocs_doc_api::EntitySpec::Curve(Curve2Spec::Line { - start: [0.0; 3], - end: [1.0; 3], - layer: Some("Walls".to_string()), - }), - ocs_doc_api::EntitySpec::Curve(Curve2Spec::Point { - position: [2.0; 3], - layer: None, - }), - ]) - .unwrap(); - - assert_eq!(ids[0].layer().unwrap(), "WALLS"); - assert_eq!(ids[1].layer().unwrap(), "0"); -} - -#[test] -fn create_many_rejects_missing_layer_before_mutating() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let rev0 = doc.revision().unwrap().as_u64(); - - let err = doc - .entities() - .create_many(&[ - ocs_doc_api::EntitySpec::Curve(Curve2Spec::Line { - start: [0.0; 3], - end: [1.0; 3], - layer: Some("Missing".to_string()), - }), - ocs_doc_api::EntitySpec::Curve(Curve2Spec::Point { - position: [2.0; 3], - layer: None, - }), - ]) - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); - - let backend = tp.backend(); - assert_eq!(backend.revision, rev0); - assert_eq!(backend.next_id, 0); -} - -#[test] -fn create_line_on_layer_requires_existing_layer() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let err = doc - .curves() - .create_line_on_layer([0.0; 3], [1.0; 3], "Missing") - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); -} diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs deleted file mode 100644 index d7fc6c3dd..000000000 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ /dev/null @@ -1,416 +0,0 @@ -//! Spec↔wire consistency tests (enforces the codegen contract the headers -//! promise): every `op`/`query` name in `spec/entities.toml` maps to an existing -//! `Operation`/`Query` variant, and the wire enums are append-only relative to a -//! recorded baseline (bincode discriminant stability). - -use std::collections::BTreeSet; - -use serde::Deserialize; - -#[derive(Deserialize)] -struct Spec { - #[serde(default)] - family: Vec, -} -#[derive(Deserialize)] -struct Family { - #[serde(default)] - handle: Option, - #[serde(default)] - constructor: Vec, - #[serde(default)] - method: Vec, -} -#[derive(Deserialize)] -struct MethodRef { - #[serde(default)] - op: Option, - #[serde(default)] - query: Option, -} - -fn spec() -> Spec { - let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/spec/entities.toml")) - .expect("read spec/entities.toml"); - toml::from_str(&src).expect("parse spec/entities.toml") -} - -/// The variant names of the `Operation` enum, in declaration order. -fn operation_variants() -> Vec { - use ocs_doc_api::ops::{ - BoolOp, Curve2Spec, InsertSpec, LayerInfo, Operation, PlacementSpec, SolidPrimitive, - ViewportSpec, - }; - use ocs_doc_api::ObjectId; - let _marker: Vec = vec![ - Operation::CreateSolid(SolidPrimitive::Sphere { - centre: [0.0; 3], - radius: 1.0, - }), - Operation::CreateCurve(Curve2Spec::Point { - position: [0.0; 3], - layer: None, - }), - Operation::Extrude { - profile: ObjectId::from_u64(0), - direction: [0.0; 3], - }, - Operation::Revolve { - profile: ObjectId::from_u64(0), - axis: ([0.0; 3], [0.0; 3]), - angle: 0.0, - }, - Operation::Transform { - id: ObjectId::from_u64(0), - placement: PlacementSpec::IDENTITY, - }, - Operation::SolidBoolean { - op: BoolOp::Union, - a: ObjectId::from_u64(0), - b: ObjectId::from_u64(0), - erase_sources: true, - }, - Operation::AddVertex { - id: ObjectId::from_u64(0), - at: 0, - point: [0.0; 3], - }, - Operation::Delete { - id: ObjectId::from_u64(0), - }, - Operation::CreateMany(vec![]), - Operation::TransformMany { - ids: vec![], - placement: PlacementSpec::IDENTITY, - }, - Operation::DeleteMany(vec![]), - Operation::CreateInsert(InsertSpec { - block_name: String::new(), - insert_point: [0.0; 3], - scale: 1.0, - rotation: 0.0, - }), - Operation::CreateViewport(ViewportSpec { - center: [0.0; 3], - width: 0.0, - height: 0.0, - view_target: [0.0; 3], - view_height: 0.0, - }), - Operation::CreateText(ocs_doc_api::ops::TextSpec { - value: String::new(), - insertion_point: [0.0; 3], - height: 0.0, - rotation: 0.0, - }), - Operation::CreateMText(ocs_doc_api::ops::MTextSpec { - value: String::new(), - insertion_point: [0.0; 3], - height: 0.0, - }), - Operation::SetTextContent { - id: ObjectId::from_u64(0), - value: String::new(), - }, - Operation::CreateHatch(ocs_doc_api::ops::HatchSpec { - boundary: vec![], - solid: true, - }), - Operation::CreateDimensionLinear(ocs_doc_api::ops::DimensionSpec { - first_point: [0.0; 3], - second_point: [0.0; 3], - definition_point: [0.0; 3], - }), - Operation::SetAttribute { - id: ObjectId::from_u64(0), - tag: String::new(), - value: String::new(), - }, - Operation::SetViewportView { - id: ObjectId::from_u64(0), - view_target: [0.0; 3], - view_height: 0.0, - }, - Operation::CreateRasterImage(ocs_doc_api::ops::RasterImageSpec { - file_path: String::new(), - insertion_point: [0.0; 3], - u_vector: [0.0; 3], - v_vector: [0.0; 3], - size: [0.0; 2], - }), - Operation::Loft { profiles: vec![] }, - Operation::CreateDimensionRadius(ocs_doc_api::ops::DimensionRadialSpec { - center: [0.0; 3], - point: [0.0; 3], - }), - Operation::CreateDimensionDiameter(ocs_doc_api::ops::DimensionRadialSpec { - center: [0.0; 3], - point: [0.0; 3], - }), - Operation::CreateDimensionAngular(ocs_doc_api::ops::DimensionAngularSpec { - vertex: [0.0; 3], - first_point: [0.0; 3], - second_point: [0.0; 3], - arc_location: [0.0; 3], - }), - Operation::CreateAttributeDefinition(ocs_doc_api::ops::AttributeDefinitionSpec { - tag: String::new(), - prompt: String::new(), - default_value: String::new(), - insertion_point: [0.0; 3], - height: 0.0, - rotation: 0.0, - }), - Operation::CreateTable(ocs_doc_api::ops::TableSpec { - insertion_point: [0.0; 3], - data: vec![], - }), - Operation::CreateDimensionAngular2Ln(ocs_doc_api::ops::DimensionAngularSpec { - vertex: [0.0; 3], - first_point: [0.0; 3], - second_point: [0.0; 3], - arc_location: [0.0; 3], - }), - Operation::SetXData { - id: ObjectId::from_u64(0), - application_name: String::new(), - record: None, - }, - Operation::SetXDataMany(vec![]), - Operation::CreateXRecord(ocs_doc_api::ops::XRecordSpec { - name: String::new(), - cloning_flags: ocs_doc_api::ops::XRecordCloningFlags::NotApplicable, - entries: vec![], - }), - Operation::SetXRecord { - id: ObjectId::from_u64(0), - spec: ocs_doc_api::ops::XRecordSpec { - name: String::new(), - cloning_flags: ocs_doc_api::ops::XRecordCloningFlags::NotApplicable, - entries: vec![], - }, - }, - Operation::CreateLayer(LayerInfo::new("LAYER")), - Operation::UpdateLayer { - name: String::new(), - info: LayerInfo::new("LAYER"), - }, - Operation::DeleteLayer { - name: String::new(), - }, - Operation::SetEntityLayer { - id: ObjectId::from_u64(0), - layer: String::new(), - }, - ]; - _marker - .iter() - .enumerate() - .map(|(index, op)| { - let wire = bincode::serialize(op).unwrap(); - assert_eq!( - u32::from_le_bytes(wire[..4].try_into().unwrap()) as usize, - index - ); - let dbg = format!("{:?}", op); - dbg.split(['(', ' ', '{']) - .next() - .unwrap_or("") - .to_string() - }) - .collect() -} - -/// The variant names of the `Query` enum, in declaration order. -fn query_variants() -> Vec { - use ocs_doc_api::query::Query; - use ocs_doc_api::ObjectId; - let marker: Vec = vec![ - Query::GetEntity { - id: ObjectId::from_u64(0), - }, - Query::GetBounds { - id: ObjectId::from_u64(0), - }, - Query::GetCentroid { - id: ObjectId::from_u64(0), - }, - Query::GetVolume { - id: ObjectId::from_u64(0), - }, - Query::GetIntersects { - a: ObjectId::from_u64(0), - b: ObjectId::from_u64(0), - }, - Query::GetGeometryRevision, - Query::GetTextContent { - id: ObjectId::from_u64(0), - }, - Query::GetHatchBoundary { - id: ObjectId::from_u64(0), - }, - Query::GetDimensionMeasurement { - id: ObjectId::from_u64(0), - }, - Query::GetAttributes { - id: ObjectId::from_u64(0), - }, - Query::GetBlockEntities { - block_name: String::new(), - }, - Query::GetViewportView { - id: ObjectId::from_u64(0), - }, - Query::GetXData { - id: ObjectId::from_u64(0), - application_name: String::new(), - }, - Query::GetXRecord { - id: ObjectId::from_u64(0), - }, - Query::ListLayers, - Query::GetEntityLayer { - id: ObjectId::from_u64(0), - }, - ]; - marker - .iter() - .enumerate() - .map(|(index, q)| { - let wire = bincode::serialize(q).unwrap(); - assert_eq!( - u32::from_le_bytes(wire[..4].try_into().unwrap()) as usize, - index - ); - let dbg = format!("{:?}", q); - dbg.split(['(', ' ', '{']) - .next() - .unwrap_or("") - .to_string() - }) - .collect() -} - -#[test] -fn every_spec_op_and_query_maps_to_an_enum_variant() { - let spec = spec(); - let op_names: BTreeSet = operation_variants().into_iter().collect(); - let query_names: BTreeSet = query_variants().into_iter().collect(); - - let mut missing = Vec::new(); - for fam in &spec.family { - for m in fam.constructor.iter().chain(fam.method.iter()) { - if let Some(op) = &m.op { - if !op_names.contains(op) { - missing.push(format!( - "Operation::{op} (family {})", - fam.handle.as_deref().unwrap_or("?") - )); - } - } - if let Some(q) = &m.query { - if !query_names.contains(q) { - missing.push(format!( - "Query::{q} (family {})", - fam.handle.as_deref().unwrap_or("?") - )); - } - } - } - } - assert!( - missing.is_empty(), - "spec references ops/queries with no enum variant: {missing:?}" - ); -} - -/// Recorded baseline of the `Operation` variant order (append-only contract). -/// New variants must be APPENDED after these; none may be removed or reordered. -const OPERATION_BASELINE: &[&str] = &[ - "CreateSolid", - "CreateCurve", - "Extrude", - "Revolve", - "Transform", - "SolidBoolean", - "AddVertex", - "Delete", - "CreateMany", - "TransformMany", - "DeleteMany", - "CreateInsert", - "CreateViewport", - "CreateText", - "CreateMText", - "SetTextContent", - "CreateHatch", - "CreateDimensionLinear", - "SetAttribute", - "SetViewportView", - "CreateRasterImage", - "Loft", - "CreateDimensionRadius", - "CreateDimensionDiameter", - "CreateDimensionAngular", - "CreateAttributeDefinition", - "CreateTable", - "CreateDimensionAngular2Ln", - "SetXData", - "SetXDataMany", - "CreateXRecord", - "SetXRecord", - "CreateLayer", - "UpdateLayer", - "DeleteLayer", - "SetEntityLayer", -]; - -/// Recorded baseline of the `Query` variant order. -const QUERY_BASELINE: &[&str] = &[ - "GetEntity", - "GetBounds", - "GetCentroid", - "GetVolume", - "GetIntersects", - "GetGeometryRevision", - "GetTextContent", - "GetHatchBoundary", - "GetDimensionMeasurement", - "GetAttributes", - "GetBlockEntities", - "GetViewportView", - "GetXData", - "GetXRecord", - "ListLayers", - "GetEntityLayer", -]; - -#[test] -fn operation_enum_is_append_only_vs_baseline() { - let current = operation_variants(); - assert!( - current.len() >= OPERATION_BASELINE.len(), - "Operation enum shrank: {current:?} < baseline {OPERATION_BASELINE:?}" - ); - for (i, name) in OPERATION_BASELINE.iter().enumerate() { - assert_eq!( - current[i], *name, - "Operation variant {i} changed (append-only violation): {current:?}" - ); - } -} - -#[test] -fn query_enum_is_append_only_vs_baseline() { - let current = query_variants(); - assert!( - current.len() >= QUERY_BASELINE.len(), - "Query enum shrank: {current:?} < baseline {QUERY_BASELINE:?}" - ); - for (i, name) in QUERY_BASELINE.iter().enumerate() { - assert_eq!( - current[i], *name, - "Query variant {i} changed (append-only violation): {current:?}" - ); - } -} diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index d8ed9a7b0..7a284bc60 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -908,7 +908,7 @@ fn generate_version_info(out_dir: &Path) { "acadrust_version": acadrust.version.to_string(), "acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()), "rustc_version": rustc_version, - "api_version": 6, + "api_version": 5, "api_version_min_supported": 2, "build_timestamp": build_timestamp, }); diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index dfc2acbf4..19130c596 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -552,18 +552,6 @@ pub trait HostApi { let _ = tab_id; None } - - /// DocApi v2 (`ocs_doc_api`): dispatch one bincode-serialized `DocApiEnvelope` - /// (one write op OR a read-only query batch) and return the bincode-serialized - /// `Result`. Opaque bytes: - /// this crate does not depend on `ocs_doc_api`, it only routes the envelope. - /// - /// Added for the DocApi v2 adapter. The default returns `Err` ("not supported"); - /// the in-process host (`HostSession`) overrides it to call the DocApi executor. - fn doc_api_dispatch(&mut self, tab_id: u64, bytes: &[u8]) -> Result, String> { - let _ = (tab_id, bytes); - Err("DocApi v2 not supported by this host".to_string()) - } } /// Simplified, read-only entity kind exposed by [`DocumentReader`]. diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index 585f2cc85..c20cad1b2 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -138,12 +138,6 @@ pub enum PluginRequest { GetTabId, /// V5: ask the host for the filesystem path of the document in `tab_id`. DocumentPath { tab_id: u64 }, - /// DocApi v2 (`ocs_doc_api`): carry one bincode-serialized `DocApiEnvelope` - /// (one write op OR a read-only query batch). Appended at the end to keep - /// existing discriminants stable. The host routes this to the DocApi executor. - /// `serde_bytes` keeps the payload a single length-prefixed bulk copy (the - /// outer frame would otherwise walk it byte-by-byte). - DocApiRequest { tab_id: u64, #[serde(with = "serde_bytes")] bytes: Vec }, } /// Responses the host sends back for `PluginRequest`. @@ -170,10 +164,6 @@ pub enum PluginResponse { TabId(u64), /// V5: filesystem path of the document in the requested tab, if any. DocumentPath(Option), - /// DocApi v2 (`ocs_doc_api`): the bincode-serialized `Result` answering a - /// `PluginRequest::DocApiRequest`. Appended at the end (discriminant stability). - /// `serde_bytes` keeps the payload a single length-prefixed bulk copy. - DocApiResponse { #[serde(with = "serde_bytes")] bytes: Vec }, } /// Messages sent from the host to the plugin runner. diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index 8394d456e..055880ef0 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -77,9 +77,5 @@ pub fn handle_plugin_request( DocumentPath { tab_id } => PluginResponse::DocumentPath( host.document_path(tab_id).map(|path| path.into_os_string()), ), - DocApiRequest { tab_id, bytes } => match host.doc_api_dispatch(tab_id, &bytes) { - Ok(bytes) => PluginResponse::DocApiResponse { bytes }, - Err(msg) => PluginResponse::Error(msg), - }, } } diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index b72648a72..b0e4b3dcd 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -18,15 +18,9 @@ /// plugins and appends `document_reader` / `document_view` at the end of the /// vtable so API v2 plugins keep working. v4 adds full-duplex notifications on /// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5 -/// adds the `BuiltinPlugin::on_load` lifecycle callback. -/// Host ABI major version. Bumped to 6 when the DocApi v2 surface -/// (`PluginRequest::DocApiRequest` / `PluginResponse::DocApiResponse` + -/// `HostApi::doc_api_dispatch`) was added: plugins that call DocApi require an -/// API-6 host and are rejected at handshake by older (API ≤ 5) hosts, instead of -/// failing at runtime with a bincode-decode transport error. The DocApi wire -/// changes are append-only and `doc_api_dispatch` is a defaulted method, so -/// older V2–V5 plugins still load unchanged on an API-6 host. -pub const API_VERSION: u32 = 6; +/// adds the `BuiltinPlugin::on_load` lifecycle callback and +/// `HostApi::document_path`. +pub const API_VERSION: u32 = 5; /// Oldest plugin API major the current host still loads. This keeps previously /// compiled cdylibs usable as long as their vtable layout is a prefix of the diff --git a/src/app/automation.rs b/src/app/automation.rs index b6038532a..13584bd87 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -323,6 +323,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.material_base_dir = path_buf.parent().map(PathBuf::from); crate::app::style_ops::ensure_standard_styles( &mut self.tabs[i].scene.document, @@ -1610,4 +1612,3 @@ mod tests { assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); } } - diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 87635b15f..03dab8980 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -312,6 +312,33 @@ impl OpenCADStudio { true } + /// Refresh screen-space feature picking immediately before point dispatch. + pub(super) fn refresh_command_point_pick_context(&mut self, i: usize) { + if self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.wants_point_pick_context()) + { + let scene = &self.tabs[i].scene; + let size = scene.selection.borrow().vp_size; + let edit = scene.viewport_edit_frame(size); + let tile = edit.as_ref().map(|(_, rect)| *rect) + .unwrap_or_else(|| scene.active_model_tile_bounds(size.0, size.1)); + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: tile.width, height: tile.height }; + let context = if bounds.width > 0.0 && bounds.height > 0.0 { + let (view, eye) = if let Some((camera, _)) = edit { + (camera.view_proj_rte(bounds), camera.eye()) + } else { + let camera = scene.camera.borrow(); + (camera.view_proj_rte(bounds), camera.eye()) + }; + Some(crate::command::PointPickContext { view, eye, bounds, + aperture_px: crate::ui::overlay::pick_box_aperture_px(self.pick_box) }) + } else { None }; + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.set_point_pick_context(context); + } + } + } + /// Drive the active command's step machine with one [`StepInput`], then /// apply the result. This is the single entry point every input source /// (command line, headless, dynamic input, plugin API, viewport) funnels @@ -390,10 +417,13 @@ impl OpenCADStudio { } } if let StepInput::SelectionComplete(handles) = &input { + let exclude_locked = self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.selection_entities_exclude_locked()); let entities = { let scene = &self.tabs[i].scene; handles .iter() + .filter(|handle| !exclude_locked || !scene.is_layer_locked(**handle)) .filter_map(|handle| { scene.document.get_entity(*handle).cloned().map(|entity| { let surface_area = scene @@ -414,6 +444,7 @@ impl OpenCADStudio { command.inject_selection_entities(entities); } } + if matches!(&input, StepInput::Point(_)) { self.refresh_command_point_pick_context(i); } let ctrl = self.ctrl_down; let shift = self.shift_down; let result: Option = { @@ -915,6 +946,323 @@ impl OpenCADStudio { TableCellEditStart::Started } + /// Adds inferred Coincident constraints after a line is committed. + /// Holding Shift suppresses inference. No match means no undo entry. + fn infer_and_add_coincident_constraints(&mut self, i: usize, new_handle: Handle, entity: &acadrust::EntityType) { + if self.shift_down { + return; + } + let scope = self.tabs[i].current_sketch_scope(); + let pairs = crate::scene::sketch_constraints::infer_coincident_refs( + &self.tabs[i].scene.document, + scope, + new_handle, + entity, + ); + if pairs.is_empty() { + return; + } + let constraints_before = self + .tabs[i] + .scene + .sketch_constraint_set(scope) + .cloned() + .unwrap_or_else(|| crate::scene::sketch_constraints::SketchConstraintSet::new(scope)); + let mut touched: Vec = Vec::new(); + for (new_ref, existing_ref) in &pairs { + touched.push(new_ref.entity); + touched.push(existing_ref.entity); + } + touched.sort(); + touched.dedup(); + let label = "Coincident (inferred)"; + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_sketch_constraints_before(scope, constraints_before); + for (new_ref, existing_ref) in pairs { + self.tabs[i].scene.sketch_constraint_set_mut(scope).add( + crate::scene::sketch_constraints::ConstraintKind::Coincident, + vec![new_ref, existing_ref], + 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); + } + } + + /// Removes and re-solves the first conflicting constraint in the active scope. + pub(super) fn resolve_one_sketch_conflict(&mut self) { + let i = self.active_tab; + let scope = self.tabs[i].current_sketch_scope(); + 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 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] + .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(); + self.tabs[i].scene.bump_entities(&changes); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + /// Rebuilds the active tab's `Scene::named_parameters` from the + /// editor's working buffer and re-solves dependent constraints. + pub(super) fn apply_named_parameter_editor_rows(&mut self) { + let i = self.active_tab; + // A name shared by more than one row can't be resolved by picking + // one arbitrarily (`ParameterTable::set` would just let the last + // one silently win) -- refuse every row sharing that name up front, + // same as `named_parameters::preview`'s live check. + let duplicates = crate::ui::window::named_parameters::duplicate_name_rows(&self.named_parameter_editor_rows); + let mut table = crate::scene::named_parameters::ParameterTable::new(); + let mut failed = 0usize; + for (idx, row) in self.named_parameter_editor_rows.iter().enumerate() { + let name = row.name.trim(); + if name.is_empty() || duplicates.contains(&idx) { + continue; + } + if let Err(e) = table.set(name, row.formula.trim()) { + self.command_line.push_error(crate::tf!("Parameter '{}': {}", name, e).as_ref()); + failed += 1; + } + } + if !duplicates.is_empty() { + self.command_line.push_error(crate::tf!("{} row(s) skipped: duplicate parameter name.", duplicates.len()).as_ref()); + failed += duplicates.len(); + } + let param_count = table.len(); + + // The whole table just changed, not one isolated value, so there is + // no cheaper "which handles actually moved" test worth doing here — + // re-solve every scope with a constraint driven by *any* named + // parameter (mirrors `solve_scope`'s own "full rebuild, not + // incremental" philosophy). + let touched: Vec = self.tabs[i] + .scene + .sketch_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .filter(|c| c.enabled && matches!(c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(_)))) + .flat_map(|c| c.refs.iter().map(|r| r.entity)) + .collect(); + + let pending = self.begin_undo(i, "Apply named parameters", touched.len(), true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = table; + self.tabs[i].dirty = true; + if !touched.is_empty() { + let 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); + } + if failed == 0 { + self.command_line.push_info(crate::tf!("{} parameter(s) applied.", param_count).as_ref()); + } + } + + /// Re-solves every entity touched by an enabled constraint driven by + /// `name` — the targeted, single-row counterpart to + /// `apply_named_parameter_editor_rows`'s "re-solve what it affects": an + /// inline Properties-panel edit changes one parameter, not the whole + /// table, so unlike that whole-table Apply this can cheaply scope the + /// re-solve to just the entities that actually reference it. + fn resolve_named_parameter_edit(&mut self, i: usize, name: &str) { + let touched: Vec = self.tabs[i] + .scene + .sketch_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .filter(|c| { + c.enabled + && matches!(&c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(n)) if n == name) + }) + .flat_map(|c| c.refs.iter().map(|r| r.entity)) + .collect(); + self.tabs[i].dirty = true; + if touched.is_empty() { + return; + } + let changes: Vec<(Handle, crate::scene::ChangeKind)> = + touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); + self.tabs[i].scene.bump_entities(&changes); + } + + /// Commits one field of one Parameters-section row (Properties panel) — + /// the per-row, commit-on-submit counterpart to + /// `apply_named_parameter_editor_rows`'s whole-table Apply. A Formula + /// edit is a plain redefine (`ParameterTable::set` on the existing + /// name); a Name edit is a remove-then-set (there's no rename primitive + /// — `ParameterTable` upserts by name), restoring the old name if the + /// new one fails to validate so nothing is silently lost. Either way + /// this only re-solves what actually depends on this one parameter — + /// no "duplicate name" scan across the whole table is needed the way + /// the buffered modal Apply needs one, since every row here is always + /// already a real, distinct table entry. + pub(super) fn on_prop_param_commit( + &mut self, + index: usize, + field: crate::ui::window::named_parameters::ParamField, + ) -> Task { + use crate::ui::properties::FieldKey; + use crate::ui::window::named_parameters::ParamField; + let i = self.active_tab; + let key = FieldKey::Param(index, field); + self.tabs[i].properties.active_field = None; + let Some(typed) = self.tabs[i].properties.edit_buf.remove(&key) else { + return Task::none(); + }; + let typed = typed.trim().to_string(); + let Some(current) = self.tabs[i].scene.named_parameters().iter().nth(index).cloned() else { + self.refresh_properties(); + return Task::none(); + }; + + match field { + ParamField::Formula if typed.is_empty() || typed == current.source => { + self.refresh_properties(); + return Task::none(); + } + ParamField::Name if typed.is_empty() || typed == current.name => { + self.refresh_properties(); + return Task::none(); + } + ParamField::Name if self.tabs[i].scene.named_parameters().contains(&typed) => { + self.command_line.push_error(crate::tf!("Parameter '{}' already exists.", typed).as_ref()); + self.refresh_properties(); + return Task::none(); + } + _ => {} + } + + let pending = self.begin_undo(i, "Edit named parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + + let (result, resolve_name) = match field { + ParamField::Formula => + (self.tabs[i].scene.named_parameters_mut().set(¤t.name, &typed), current.name.clone()), + ParamField::Name => { + self.tabs[i].scene.named_parameters_mut().remove(¤t.name); + let outcome = self.tabs[i].scene.named_parameters_mut().set(&typed, ¤t.source); + if outcome.is_err() { + let _ = self.tabs[i].scene.named_parameters_mut().set(¤t.name, ¤t.source); + } + (outcome, typed.clone()) + } + }; + + if let Err(e) = result { + self.tabs[i].scene.take_undo_recording(); + self.command_line.push_error(crate::tf!("Parameter '{}': {}", current.name, e).as_ref()); + self.refresh_properties(); + return Task::none(); + } + + if field == ParamField::Name { + let affected: Vec = self.tabs[i] + .scene + .sketch_constraints + .iter() + .enumerate() + .filter_map(|(index, set)| { + set.constraints + .iter() + .any(|constraint| { + matches!(&constraint.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(name)) if name == ¤t.name) + }) + .then_some(index) + }) + .collect(); + for set_index in affected { + let scope = self.tabs[i].scene.sketch_constraints[set_index].scope; + let before = self.tabs[i].scene.sketch_constraints[set_index].clone(); + self.tabs[i].scene.record_undo_sketch_constraints_before(scope, before); + for constraint in &mut self.tabs[i].scene.sketch_constraints[set_index].constraints { + if let Some(crate::scene::named_parameters::DrivingValue::Named(name)) = &mut constraint.driving_param { + if name == ¤t.name { + *name = typed.clone(); + } + } + } + } + } + self.resolve_named_parameter_edit(i, &resolve_name); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + self.refresh_properties(); + Task::none() + } + + /// Removes Parameters-section row `index` immediately. Any + /// constraint that referenced it gets the same "undefined reference" + /// resolve-failure treatment a formula's own bad reference already gets + /// (`build_constraint`) — re-solving surfaces that rather than needing + /// special-case handling here. + pub(super) fn on_prop_param_delete(&mut self, index: usize) -> Task { + let i = self.active_tab; + let Some(name) = self.tabs[i].scene.named_parameters().iter().nth(index).map(|p| p.name.clone()) else { + return Task::none(); + }; + let pending = self.begin_undo(i, "Delete named parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters_mut().remove(&name); + self.resolve_named_parameter_edit(i, &name); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + self.refresh_properties(); + Task::none() + } + + /// The Parameters section's "+ Add parameter" row: appends a fresh, + /// uniquely-named parameter (`param1`, `param2`, …) the user then + /// renames/redefines inline via `on_prop_param_commit`. Defaults to `1`, + /// not `0`: renaming this onto a name an existing Distance/Radius + /// constraint already references (unusual, but reachable — define + /// first, wire up the constraint's reference later) would otherwise + /// briefly collapse that geometry to a zero-length/zero-radius + /// degenerate state, which is a genuine numerical singularity for a + /// point-to-point distance constraint to grow back out of (no defined + /// direction to separate two exactly-coincident points from). `1` never + /// hits that. + pub(super) fn on_prop_param_add_new(&mut self) -> Task { + let i = self.active_tab; + let mut n = 1usize; + let name = loop { + let candidate = format!("param{n}"); + if !self.tabs[i].scene.named_parameters().contains(&candidate) { + break candidate; + } + n += 1; + }; + let pending = self.begin_undo(i, "Add named parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + let _ = self.tabs[i].scene.named_parameters_mut().set(&name, "1"); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + self.refresh_properties(); + Task::none() + } + pub(in crate::app) fn start_mtp_modifier(&mut self, i: usize) { let parent = self.tabs[i].active_cmd.take(); self.tabs[i].suspended_cmd = parent; @@ -927,8 +1275,256 @@ impl OpenCADStudio { self.refresh_active_cmd_preview(i); } + /// Apply a command replacement without deleting a one-to-one entity edit. + /// Splits and entity-type conversions still allocate replacement identities. + fn replace_command_entity( + &mut self, + tab: usize, + handle: Handle, + mut entities: Vec, + ) -> Vec { + let same_type = entities.len() == 1 && self.tabs[tab].scene.document.get_entity(handle) + .is_some_and(|original| std::mem::discriminant(original) == std::mem::discriminant(&entities[0])); + let handles = if same_type { + let mut entity = entities.pop().expect("one replacement"); + entity.common_mut().handle = handle; + // Scene::update_entity also retains the live owning block and + // refreshes dependent render caches without erase notifications. + if self.tabs[tab].scene.update_entity(entity) { vec![handle] } else { Vec::new() } + } else { + self.tabs[tab].scene.erase_entities(&[handle]); + entities.into_iter().map(|entity| self.tabs[tab].scene.add_entity(entity)).collect() + }; + for &updated in &handles { + if matches!(self.tabs[tab].scene.document.get_entity(updated), Some(acadrust::EntityType::Dimension(_))) { + self.tabs[tab].scene.invalidate_dim_block_recorded(updated); + } + } + handles + } + + fn apply_pedit_result( + &mut self, + tab: usize, + handle: Handle, + op: crate::modules::draw::modify::pedit::PeditOp, + ) -> Task { + use crate::modules::draw::modify::pedit::{apply_pedit, convert_to_polyline, PeditOp}; + + if !matches!( + &op, + PeditOp::Multiple(_, _) | PeditOp::JoinSelection(_, _) + ) + && self.reject_locked_edit(tab, handle) + { + return Task::none(); + } + match &op { + PeditOp::JoinSelection(handles, fuzz) => { + let mut available = handles + .iter() + .filter_map(|handle| { + if self.tabs[tab].scene.is_layer_locked(*handle) { + return None; + } + self.tabs[tab] + .scene + .document + .get_entity(*handle) + .cloned() + .map(|entity| (*handle, entity)) + }) + .collect::>(); + let mut changes = Vec::new(); + while !available.is_empty() { + let (source_handle, source) = available.remove(0); + let candidates = available + .iter() + .map(|(handle, entity)| (*handle, entity)) + .collect::>(); + if let Some((mut result, consumed)) = + crate::modules::draw::modify::pedit::join_selection_extend( + &source, + &candidates, + *fuzz, + ) + { + *result.common_mut() = source.common().clone(); + available.retain(|(handle, _)| !consumed.contains(handle)); + changes.push((source_handle, result, consumed)); + } + } + if !changes.is_empty() { + self.push_undo_snapshot(tab, "PEDIT"); + for (source, replacement, consumed) in changes { + self.tabs[tab].scene.erase_entities(&consumed); + self.tabs[tab].scene.update_entity(replacement.clone()); + if let Some(command) = self.tabs[tab].active_cmd.as_mut() { + for old in consumed { + command.on_entity_replaced(old, &[source]); + } + command.inject_picked_entity(replacement); + } + } + if let Some(command) = self.tabs[tab].active_cmd.as_mut() { + command.on_pedit_applied(); + } + self.tabs[tab].dirty = true; + self.refresh_properties(); + } + } + PeditOp::Multiple(handles, operation) => { + let replacements: Vec<_> = handles + .iter() + .filter(|handle| !self.tabs[tab].scene.is_layer_locked(**handle)) + .filter_map(|handle| { + let original = self.tabs[tab].scene.document.get_entity(*handle)?; + if matches!(operation.as_ref(), PeditOp::ConvertToPolyline) { + convert_to_polyline(original) + .map(|replacement| (*handle, replacement, true)) + } else { + let mut replacement = original.clone(); + apply_pedit(&mut replacement, operation) + .then_some((*handle, replacement, false)) + } + }) + .collect(); + if replacements.is_empty() { + return Task::none(); + } + self.push_undo_snapshot(tab, "PEDIT"); + for (handle, replacement, converted) in replacements { + let updated_handle = if converted { + self.tabs[tab].scene.erase_entities(&[handle]); + let new_handle = self.tabs[tab].scene.add_entity(replacement); + if let Some(command) = self.tabs[tab].active_cmd.as_mut() { + command.on_entity_replaced(handle, &[new_handle]); + } + new_handle + } else { + if let Some(entity) = self.tabs[tab].scene.document.get_entity_mut(handle) { + *entity = replacement; + } + self.tabs[tab].scene.refresh_fill_model(handle); + self.tabs[tab] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + handle + }; + let updated = self.tabs[tab] + .scene + .document + .get_entity(updated_handle) + .cloned(); + if let (Some(command), Some(entity)) = + (self.tabs[tab].active_cmd.as_mut(), updated) + { + command.inject_picked_entity(entity); + } + } + if let Some(command) = self.tabs[tab].active_cmd.as_mut() { + command.on_pedit_applied(); + } + self.tabs[tab].dirty = true; + self.refresh_properties(); + } + PeditOp::VertexRange { first, last, split } => { + let pieces = self.tabs[tab] + .scene + .document + .get_entity(handle) + .and_then(|entity| { + crate::modules::draw::modify::pedit::edit_vertex_range( + entity, *first, *last, *split, + ) + }); + if let Some(mut pieces) = pieces { + self.push_undo_snapshot(tab, "PEDIT"); + let source = pieces.remove(0); + self.tabs[tab].scene.update_entity(source); + for mut piece in pieces { + piece.common_mut().handle = Handle::NULL; + self.tabs[tab].scene.add_entity(piece); + } + let updated = self.tabs[tab].scene.document.get_entity(handle).cloned(); + if let Some(command) = self.tabs[tab].active_cmd.as_mut() { + if let Some(entity) = updated { + command.inject_picked_entity(entity); + } + command.on_pedit_applied(); + } + self.tabs[tab].dirty = true; + self.refresh_properties(); + } else { + self.command_line.push_error("PEDIT: invalid vertex range."); + } + } + PeditOp::ConvertToPolyline => { + let converted = self.tabs[tab] + .scene + .document + .get_entity(handle) + .and_then(convert_to_polyline); + match converted { + Some(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(), + ), + } + } + _ => { + self.push_undo_snapshot(tab, "PEDIT"); + let changed = self.tabs[tab] + .scene + .document + .get_entity_mut(handle) + .map(|entity| apply_pedit(entity, &op)) + .unwrap_or(false); + if changed { + let updated = self.tabs[tab].scene.document.get_entity(handle).cloned(); + if let Some(command) = self.tabs[tab].active_cmd.as_mut() { + if let Some(entity) = updated { + command.inject_picked_entity(entity); + } + command.on_pedit_applied(); + } + self.tabs[tab].dirty = true; + self.tabs[tab].scene.refresh_fill_model(handle); + self.tabs[tab] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + self.command_line + .push_output(crate::t!("PEDIT: applied.").as_ref()); + self.refresh_properties(); + } else { + self.discard_last_undo_entry(tab); + self.command_line.push_error( + crate::t!("PEDIT: operation not applicable to this entity.").as_ref(), + ); + } + } + } + if let Some(prompt) = self.tabs[tab].active_cmd.as_ref().map(|command| command.prompt()) { + self.command_line.push_info(&prompt); + } + Task::none() + } + fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; + if let Some(bind) = self.tabs[i].active_cmd.as_ref().and_then(|command| command.nested_copy_bind_setting()) { + if self.ncopy_bind != bind { + self.ncopy_bind = bind; + self.persist_settings_if_changed(); + } + } + let preserve_commit_style = self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.preserve_commit_style()); let preserve_commit_layer = self.tabs[i] .active_cmd .as_ref() @@ -1051,8 +1647,22 @@ impl OpenCADStudio { if let Some(pd) = pending { self.commit_undo_delta(i, pd); } + // Stage 8/§6.2: live inference, scoped to `LINE` (the design + // doc's own example) rather than every `CommitEntity` source — + // FILLET/OFFSET/MIRROR/etc. also route Lines through this same + // dispatch and shouldn't silently acquire constraints a user + // never asked for. + if let Some(handle) = committed { + let is_line_command = + self.tabs[i].active_cmd.as_ref().is_some_and(|c| c.name() == "LINE"); + if is_line_command { + if let Some(new_entity) = self.tabs[i].scene.document.get_entity(handle).cloned() { + self.infer_and_add_coincident_constraints(i, handle, &new_entity); + } + } + } } - CmdResult::CommitEntities(entities) => { + CmdResult::CommitEntities(mut entities) => { let locked_source = entities.iter().find_map(|entity| { let handle = entity.common().handle; (!handle.is_null() @@ -1065,12 +1675,23 @@ impl OpenCADStudio { return Task::none(); } let label = self.history_label_from_active_cmd(i, "ENTITY"); - let delta_safe = entities + let symbol_names = self.tabs[i].active_cmd.as_ref() + .and_then(|command| command.nested_copy_symbol_names()).cloned(); + let delta_safe = symbol_names.is_none() && entities .iter() .all(|entity| self.delta_add_safe(i, entity)); let pending = self.begin_undo(i, label, entities.len(), delta_safe); + if let Some(names) = symbol_names { + let retained = self.tabs[i].scene.document.localize_nested_copy_symbols(&mut entities, &names); + if retained > 0 { + self.command_line.push_info(&format!("{} copied object(s) retain imported style references whose dependencies cannot be localized.", retained)); + } + self.refresh_layer_panel(); + } for entity in entities { - if preserve_commit_layer { + if preserve_commit_style { + let _ = self.commit_entity_handle_preserve_style(entity); + } else if preserve_commit_layer { let _ = self.commit_entity_handle_preserve_layer(entity); } else { self.commit_entity(entity); @@ -1086,14 +1707,29 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } - CmdResult::CommitEntitiesAndExit(entities) => { + CmdResult::CommitEntitiesAndExit(mut entities) => { let label = self.history_label_from_active_cmd(i, "ENTITY"); - let delta_safe = entities + let symbol_names = self.tabs[i].active_cmd.as_ref() + .and_then(|command| command.nested_copy_symbol_names()).cloned(); + let delta_safe = symbol_names.is_none() && entities .iter() .all(|entity| self.delta_add_safe(i, entity)); let pending = self.begin_undo(i, label, entities.len(), delta_safe); + if let Some(names) = symbol_names { + let retained = self.tabs[i].scene.document.localize_nested_copy_symbols(&mut entities, &names); + if retained > 0 { + self.command_line.push_info(&format!("{} copied object(s) retain imported style references whose dependencies cannot be localized.", retained)); + } + self.refresh_layer_panel(); + } for entity in entities { - self.commit_entity(entity); + if preserve_commit_style { + let _ = self.commit_entity_handle_preserve_style(entity); + } else if preserve_commit_layer { + let _ = self.commit_entity_handle_preserve_layer(entity); + } else { + self.commit_entity(entity); + } } self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); @@ -2153,17 +2789,7 @@ impl OpenCADStudio { .is_some_and(|c| c.name() == "SS_CATCHMENT"); self.push_undo_snapshot(i, label); for (handle, entities) in replacements { - self.tabs[i].scene.erase_entities(&[handle]); - for entity in entities { - let nh = self.tabs[i].scene.add_entity(entity); - // Drop a stale *D block on any replaced dimension (#181). - if matches!( - self.tabs[i].scene.document.get_entity(nh), - Some(acadrust::EntityType::Dimension(_)) - ) { - self.tabs[i].scene.invalidate_dim_block_recorded(nh); - } - } + self.replace_command_entity(i,handle,entities); } for entity in additions { self.tabs[i].scene.add_entity(entity); @@ -2189,11 +2815,7 @@ impl OpenCADStudio { let label = self.history_label_from_active_cmd(i, "TRIM"); self.push_undo_snapshot(i, label); for (handle, entities) in replacements { - self.tabs[i].scene.erase_entities(&[handle]); - let new_handles: Vec = entities - .into_iter() - .map(|entity| self.tabs[i].scene.add_entity(entity)) - .collect(); + let new_handles = self.replace_command_entity(i,handle,entities); if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.on_entity_replaced(handle, &new_handles); } @@ -2210,6 +2832,128 @@ impl OpenCADStudio { } self.refresh_properties(); } + CmdResult::AddSketchConstraint { kind, refs, driving_param, label } => { + if let Err(message) = self.tabs[i] + .scene + .validate_sketch_constraint(kind, &refs, driving_param.as_ref()) + { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_error(message); + return Task::none(); + } + let scope = self.tabs[i].current_sketch_scope(); + let constraints_before = self + .tabs[i] + .scene + .sketch_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::sketch_constraints::SketchConstraintSet::new(scope) + }); + let touched: Vec = refs.iter().map(|r| r.entity).collect(); + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_sketch_constraints_before(scope, constraints_before); + self.tabs[i].scene.sketch_constraint_set_mut(scope).add(kind, refs, driving_param); + let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + .into_iter() + .map(|h| (h, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_output("Constraint applied."); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + CmdResult::AddCoincidentConstraint { point_a, point_b, label } => { + let scope = self.tabs[i].current_sketch_scope(); + let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); + let resolved_a = + crate::scene::sketch_constraints::nearest_sketch_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) + }); + 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, + refs: vec![ref_a, ref_b], + driving_param: None, + label, + }); + } + _ => { + 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; + } + } + } + CmdResult::AddPointOnEntityConstraint { point, target, kind, label } => { + let scope = self.tabs[i].current_sketch_scope(); + let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); + let resolved = crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point), Some(target)); + match resolved { + Some(point_ref) => { + use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; + // `CenterPoint` addresses its whole-entity side via + // the center marker (same solve as Coincident / + // Concentric — `ConstraintKind::CenterPoint`'s own + // doc comment); `Midpoint`/`PointOnCurve` address it + // as a whole entity. + let target_ref = + if kind == ConstraintKind::CenterPoint { SketchRef::center(target) } else { SketchRef::whole(target) }; + return self.apply_cmd_result(CmdResult::AddSketchConstraint { + kind, + refs: vec![point_ref, target_ref], + driving_param: None, + label, + }); + } + None => { + self.command_line.push_error( + "Pick didn't land on a point (endpoint, center, or vertex) — enable an Endpoint/Center object snap and try again.", + ); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + } + } + } + CmdResult::AddEqualDistanceConstraint { points, label } => { + let scope = self.tabs[i].current_sketch_scope(); + let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); + let mut refs = Vec::with_capacity(4); + for p in points { + let Some(r) = crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(p), None) + else { + refs.clear(); + break; + }; + refs.push(r); + } + if refs.len() == 4 { + return self.apply_cmd_result(CmdResult::AddSketchConstraint { + kind: crate::scene::sketch_constraints::ConstraintKind::EqualDistance, + refs, + driving_param: None, + label, + }); + } + self.command_line.push_error( + "Equal Distance: a pick didn't land on a point (endpoint, center, or vertex) — enable an Endpoint/Center object snap and try again.", + ); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + } CmdResult::ReassociateCenterMark { target, source, point } => { if self.reject_locked_edit(i, target) { return Task::none(); @@ -2312,8 +3056,12 @@ impl OpenCADStudio { self.tabs[i].scene.invalidate_dim_block_recorded(nh); } } + let pedit_entities = if self.tabs[i].active_cmd.as_ref().is_some_and(|command| command.name() == "PEDIT") { + new_handles.iter().filter_map(|new| self.tabs[i].scene.document.get_entity(*new).cloned()).collect::>() + } else { Vec::new() }; if let Some(cmd) = &mut self.tabs[i].active_cmd { cmd.on_entity_replaced(handle, &new_handles); + for entity in pedit_entities { cmd.inject_picked_entity(entity); } } self.tabs[i].dirty = true; let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); @@ -3204,64 +3952,26 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); } - CmdResult::PeditOp { handle, op } => { - if self.reject_locked_edit(i, handle) { - return Task::none(); - } - use crate::modules::draw::modify::pedit::{ - apply_pedit, convert_to_polyline, PeditOp, - }; - match &op { - // The convert replaces the entity (new handle). - PeditOp::ConvertToPolyline => { - let converted = self.tabs[i] - .scene - .document - .get_entity(handle) - .and_then(convert_to_polyline); - match converted { - Some(pl) => { - return self - .apply_cmd_result(CmdResult::ReplaceEntity(handle, vec![pl])); - } - None => self - .command_line - .push_error(crate::t!("PEDIT: cannot convert this entity.").as_ref()), - } - } - _ => { - // Snapshot BEFORE the mutation — an after-the-fact - // snapshot records the changed state and undo no-ops. - self.push_undo_snapshot(i, "PEDIT"); - let changed = self.tabs[i] - .scene - .document - .get_entity_mut(handle) - .map(|e| apply_pedit(e, &op)) - .unwrap_or(false); - if changed { - if let Some(command) = self.tabs[i].active_cmd.as_mut() { - command.on_pedit_applied(); - } - self.tabs[i].dirty = true; - // Repaint immediately (wide-band fill included). - self.tabs[i].scene.refresh_fill_model(handle); - self.tabs[i] - .scene - .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); - self.command_line.push_output(crate::t!("PEDIT: applied.").as_ref()); - self.refresh_properties(); - } else { - self.discard_last_undo_entry(i); - self.command_line - .push_error(crate::t!("PEDIT: operation not applicable to this entity.").as_ref()); - } - } - } - let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); - if let Some(p) = prompt { - self.command_line.push_info(&p); - } + 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 let Some((replacement, consumed)) = joined { + self.push_undo_snapshot(i,"JOIN"); + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(source) { *entity = replacement; } + self.tabs[i].scene.erase_entities(&consumed); + self.tabs[i].scene.refresh_fill_model(source); + self.tabs[i].scene.bump_entities(&[(source,crate::scene::ChangeKind::Modified)]); + self.tabs[i].dirty = true; + self.command_line.push_output(&format!("JOIN: {} objects joined to source.",consumed.len())); + self.refresh_properties(); + } else { self.command_line.push_info("JOIN: no compatible objects joined to source."); } + self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); } CmdResult::JoinEntities(handles) => { if let Some(handle) = handles @@ -4749,6 +5459,12 @@ impl OpenCADStudio { let active = self.tabs[i].active_cmd.take(); self.undo_active_tab(); self.tabs[i].active_cmd = active; + if self.tabs[i].active_cmd.as_ref().is_some_and(|command| command.name() == "PEDIT") { + let entities: Vec<_> = self.tabs[i].scene.document.entities().cloned().collect(); + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + for entity in entities { command.inject_picked_entity(entity); } + } + } let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { self.command_line.push_info(&p); @@ -5029,6 +5745,8 @@ impl OpenCADStudio { let groups = self.clipboard_deps.groups.clone(); 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); // 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 @@ -5971,6 +6689,409 @@ fn resample_widths(source: &[(f64, f64)], count: usize) -> Vec<(f64, f64)> { .collect() } +#[cfg(test)] +mod command_replacement_tests { + use super::*; + use acadrust::entities::{Circle, Line}; + use acadrust::types::Vector3; + + #[test] + fn one_to_one_edits_keep_identity_but_type_changes_do_not() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let tab = app.active_tab; + let handle = app.tabs[tab].scene.add_entity(acadrust::EntityType::Line( + Line::from_points(Vector3::ZERO, Vector3::new(1.0, 0.0, 0.0)), + )); + let owner = app.tabs[tab].scene.document.get_entity(handle).unwrap().common().owner_handle; + + let kept = app.replace_command_entity(tab, handle, vec![acadrust::EntityType::Line( + Line::from_points(Vector3::ZERO, Vector3::new(3.0, 0.0, 0.0)), + )]); + assert_eq!(kept, vec![handle]); + let edited = app.tabs[tab].scene.document.get_entity(handle).unwrap(); + assert_eq!(edited.common().owner_handle, owner); + assert!(matches!(edited, acadrust::EntityType::Line(line) if line.end.x == 3.0)); + + let allocated = app.replace_command_entity(tab, handle, + vec![acadrust::EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 2.0))]); + assert_eq!(allocated.len(), 1); + assert_ne!(allocated[0], handle); + assert!(app.tabs[tab].scene.document.get_entity(handle).is_none()); + } +} + +#[cfg(test)] +mod sketch_constraint_undo_tests { + use super::*; + use crate::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; + + 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), + ))) + } + + 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(), + other => panic!("expected a Line, got {other:?}"), + } + } + + /// Constraint state and solved geometry share one undo/redo transaction. + #[test] + fn adding_a_constraint_is_undoable_and_redoable_atomically() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0); + let original_angle = line_angle_deg(&app, line); + assert!((original_angle - 16.699244).abs() < 1e-3); + + let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Horizontal, + refs: vec![SketchRef::whole(line)], + driving_param: None, + label: "Horizontal constraint", + }); + 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(), + 1 + ); + + app.undo_steps(1); + assert_eq!( + app.tabs[app.active_tab] + .scene + .sketch_constraint_set(SketchScope::ModelSpace) + .map(|s| s.constraints.len()) + .unwrap_or(0), + 0, + "undo should remove the constraint record" + ); + assert!( + (line_angle_deg(&app, line) - original_angle).abs() < 1e-3, + "undo should restore the pre-constraint angle" + ); + + app.redo_steps(1); + assert!(line_angle_deg(&app, line).abs() < 1e-6, "redo should re-level the line"); + assert_eq!( + app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(), + 1, + "redo should restore the constraint record" + ); + } + + /// A line endpoint landing on an existing point infers Coincident unless + /// Shift is held. + #[test] + fn drawing_a_line_onto_an_existing_endpoint_infers_a_coincident_constraint() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let existing = add_line(&mut app, 0.0, 0.0, 5.0, 0.0); + + let new_line = acadrust::EntityType::Line(acadrust::entities::Line::from_points( + 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())); + let _ = app.apply_cmd_result(CmdResult::CommitEntity(new_line)); + + let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).expect("a constraint set should exist"); + assert_eq!(set.constraints.len(), 1, "an inferred Coincident constraint should have been added"); + let c = &set.constraints[0]; + assert_eq!(c.kind, ConstraintKind::Coincident); + let touches_existing = c.refs.iter().any(|r| r.entity == existing); + assert!(touches_existing, "the inferred constraint should reference the pre-existing line"); + } + + /// Same setup as above, but with Shift held: inference must be + /// suppressed entirely. + #[test] + fn shift_suppresses_the_coincident_inference() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let _existing = add_line(&mut app, 0.0, 0.0, 5.0, 0.0); + + let new_line = acadrust::EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(5.0, 0.0, 0.0), + 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.shift_down = true; + let _ = app.apply_cmd_result(CmdResult::CommitEntity(new_line)); + + assert_eq!( + app.tabs[app.active_tab] + .scene + .sketch_constraint_set(SketchScope::ModelSpace) + .map(|s| s.constraints.len()) + .unwrap_or(0), + 0, + "Shift should have suppressed the inference" + ); + } + + /// Conflict resolution removes one flagged constraint and is undoable. + #[test] + fn resolve_one_sketch_conflict_removes_a_flagged_constraint_and_is_undoable() { + let mut app = OpenCADStudio::new_for_test(); + 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 _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Horizontal, + refs: vec![SketchRef::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)]); + + let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap(); + assert_eq!(set.constraints.len(), 2); + 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.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"); + } + + /// `named_parameters_design.md` stage 4: applying the PARAMETERS editor's + /// working buffer both defines the parameter and re-solves whatever + /// constraint already references it by name — the entire point of a + /// *named* parameter, not just data plumbing. + #[test] + fn applying_named_parameter_rows_defines_and_resolves_referencing_constraints() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let line = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); + + // The constraint is added *before* the parameter exists -- allowed, + // same as `ParameterTable::set`'s own forward-reference tolerance; + // it just doesn't resolve to anything until the parameter is defined. + let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Distance, + refs: vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + driving_param: Some(crate::scene::named_parameters::DrivingValue::Named("target_len".to_string())), + label: "Distance constraint", + }); + + app.named_parameter_editor_rows = + vec![crate::ui::window::named_parameters::ParamEditorRow { name: "target_len".to_string(), formula: "8".to_string() }]; + app.apply_named_parameter_editor_rows(); + + assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(8.0)); + let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { + Some(acadrust::EntityType::Line(l)) => (l.start, l.end), + other => panic!("expected a Line, got {other:?}"), + }; + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!((len - 8.0).abs() < 1e-6, "length should track the applied parameter, got {len}"); + + // Redefining the parameter and re-applying should ripple again. + app.named_parameter_editor_rows[0].formula = "3".to_string(); + app.apply_named_parameter_editor_rows(); + let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { + Some(acadrust::EntityType::Line(l)) => (l.start, l.end), + other => panic!("expected a Line, got {other:?}"), + }; + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!((len - 3.0).abs() < 1e-6, "length should track the redefined parameter, got {len}"); + + app.undo_steps(1); + assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(8.0)); + let length_after_undo = match app.tabs[app.active_tab].scene.document.get_entity(line) { + Some(acadrust::EntityType::Line(line)) => line.length(), + other => panic!("expected a Line, got {other:?}"), + }; + assert!((length_after_undo - 8.0).abs() < 1e-6); + + app.redo_steps(1); + assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(3.0)); + } + + /// End-to-end through the actual `Message` handlers a Properties-panel + /// click/keystroke would fire — not `apply_named_parameter_editor_rows` + /// directly — covering the whole Parameters-section lifecycle: add, + /// rename, redefine, and confirm a referencing constraint re-solves + /// after each commit (not just after a whole-table Apply, since this + /// path commits per field). + #[test] + fn properties_panel_parameter_row_add_rename_redefine_and_delete() { + use crate::ui::window::named_parameters::ParamField; + + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let line = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); + let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Distance, + refs: vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + driving_param: Some(crate::scene::named_parameters::DrivingValue::Named("len".to_string())), + label: "Distance constraint", + }); + + // Add: the first row is auto-named "param1". + let _ = app.update(Message::PropParamAddNew); + assert!(app.tabs[app.active_tab].scene.named_parameters().contains("param1")); + let index = app.tabs[app.active_tab].scene.named_parameters().iter().position(|p| p.name == "param1").unwrap(); + + // Rename param1 -> len (commit-on-submit, not per keystroke: input + // alone must not touch the table yet). + let _ = app.update(Message::PropParamInput { index, field: ParamField::Name, value: "len".to_string() }); + assert!(app.tabs[app.active_tab].scene.named_parameters().contains("param1"), "typing alone must not commit"); + let _ = app.update(Message::PropParamCommit { index, field: ParamField::Name }); + assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("param1")); + assert!(app.tabs[app.active_tab].scene.named_parameters().contains("len")); + + // Redefine its formula to 8 and confirm the Distance constraint + // (already referencing "len" by name, defined before this) re-solves. + let _ = app.update(Message::PropParamInput { index, field: ParamField::Formula, value: "8".to_string() }); + let _ = app.update(Message::PropParamCommit { index, field: ParamField::Formula }); + assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("len"), Ok(8.0)); + let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { + Some(acadrust::EntityType::Line(l)) => (l.start, l.end), + other => panic!("expected a Line, got {other:?}"), + }; + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!((len - 8.0).abs() < 1e-6, "length should track the redefined parameter, got {len}"); + + // Delete: the row is gone from the table. + let _ = app.update(Message::PropParamDelete(index)); + assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("len")); + } + + /// A Constraints-section row click selects every entity the constraint + /// references, replacing whatever was selected before. + #[test] + fn properties_panel_constraint_link_click_selects_its_entities() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let a = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); + let b = add_line(&mut app, 0.0, 5.0, 10.0, 5.0); + let unrelated = add_line(&mut app, 20.0, 20.0, 30.0, 20.0); + let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Parallel, + refs: vec![SketchRef::whole(a), SketchRef::whole(b)], + driving_param: None, + label: "Parallel constraint", + }); + app.tabs[app.active_tab].scene.select_entity(unrelated, true); + + let _ = app.update(Message::PropConstraintLinkClick(vec![a, b])); + + let selected: std::collections::HashSet = + app.tabs[app.active_tab].scene.selected_entities().into_iter().map(|(h, _)| h).collect(); + assert_eq!(selected, std::collections::HashSet::from([a, b])); + } + + /// A row that fails to validate (here, a formula that doesn't parse) + /// must not silently vanish or corrupt the table: it's reported as an + /// error and simply isn't added, while a valid row alongside it still + /// commits — the same isolate-the-failure treatment + /// `ParameterTable::resolve_all` already gives a bad reference. + #[test] + fn applying_a_row_with_a_malformed_formula_reports_an_error_and_keeps_the_good_row() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + + app.named_parameter_editor_rows = vec![ + crate::ui::window::named_parameters::ParamEditorRow { name: "good".to_string(), formula: "10".to_string() }, + crate::ui::window::named_parameters::ParamEditorRow { name: "bad".to_string(), formula: "1 +".to_string() }, + ]; + app.apply_named_parameter_editor_rows(); + + assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("good"), Ok(10.0)); + assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("bad"), "a malformed row must not be added"); + assert!( + app.command_line.history.iter().any(|e| e.kind == crate::ui::command_line::EntryKind::Error), + "a malformed row must be reported as an error, not silently dropped" + ); + } + + /// Two rows typed with the same name must not silently collapse into + /// "whichever `set` call happens last wins" — both are refused, and an + /// unrelated row still commits normally. + #[test] + fn applying_rows_with_a_duplicate_name_refuses_both_and_keeps_the_unrelated_row() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + + app.named_parameter_editor_rows = vec![ + crate::ui::window::named_parameters::ParamEditorRow { name: "x".to_string(), formula: "1".to_string() }, + crate::ui::window::named_parameters::ParamEditorRow { name: "x".to_string(), formula: "2".to_string() }, + crate::ui::window::named_parameters::ParamEditorRow { name: "y".to_string(), formula: "3".to_string() }, + ]; + app.apply_named_parameter_editor_rows(); + + assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("x"), "a duplicated name must not be added at all"); + assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("y"), Ok(3.0)); + assert!(app.command_line.history.iter().any(|e| e.kind == crate::ui::command_line::EntryKind::Error)); + } + + /// Two endpoint picks resolve to a Coincident constraint. + #[test] + fn coincident_command_resolves_two_picked_points_into_a_constraint() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let a = add_line(&mut app, 0.0, 0.0, 5.0, 0.0); + 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 + label: "Coincident constraint", + }); + + let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::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"); + } + + /// A pick that doesn't land near any real point (no object snap match) + /// must not add a constraint — it should report an error and leave the + /// scope untouched rather than silently constraining the wrong thing. + #[test] + fn coincident_command_reports_an_error_when_a_pick_misses() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + 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 + label: "Coincident constraint", + }); + + assert_eq!( + app.tabs[app.active_tab] + .scene + .sketch_constraint_set(SketchScope::ModelSpace) + .map(|s| s.constraints.len()) + .unwrap_or(0), + 0, + "a missed pick must not add a constraint" + ); + } +} + #[cfg(all(test, not(target_arch = "wasm32")))] mod delobj_tests { use super::*; diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index d020e42cc..a0f5d2583 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -654,42 +654,13 @@ impl OpenCADStudio { } } - // NCOPY — copy the nested objects of the selected block reference(s) - // into model space, keeping the block (a non-destructive extraction; - // every nested object is copied — the block is not exploded). "NCOPY" | "NCOPYALL" => { - use crate::modules::draw::modify::explode::explode_entity; - let inserts: Vec = self.tabs[i] - .scene - .selected_entities() - .iter() - .filter(|(_, e)| matches!(e, acadrust::EntityType::Insert(_))) - .map(|(h, _)| *h) - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - if inserts.is_empty() { - self.command_line - .push_error(crate::t!("NCOPY: select a block reference first.").as_ref()); - return Some(Task::none()); - } - self.push_undo_snapshot(i, "NCOPY"); - let mut n = 0usize; - for h in &inserts { - let nested = match self.tabs[i].scene.document.get_entity(*h).cloned() { - Some(ins) => explode_entity(&ins, &self.tabs[i].scene.document), - None => Vec::new(), - }; - for e in nested { - self.tabs[i].scene.add_entity(e); - n += 1; - } - } - self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "NCOPY: copied {n} nested object(s) into model space (blocks kept)." - ).as_ref()); + let command = crate::modules::draw::modify::ncopy::NcopyCommand::new( + &self.tabs[i].scene.document, self.ncopy_bind, + ); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); } - _ => return None, } Some(self.finish_dispatch(cmd)) @@ -706,6 +677,19 @@ mod tests { app } + #[test] + fn ncopy_starts_nested_object_selection() { + let mut app = fresh_app(); + let _ = app.run_command_line("NCOPY"); + assert_eq!( + app.tabs[app.active_tab] + .active_cmd + .as_ref() + .map(|active| active.name()), + Some("NCOPY") + ); + } + /// Run one command line and return only the command-line text it appended. fn run_capture(app: &mut OpenCADStudio, cmd: &str) -> String { let start = app.command_line.history.len(); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index cf848ff7b..ec659efc1 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -596,9 +596,23 @@ impl OpenCADStudio { "REVERSE" => { use crate::modules::draw::modify::reverse::ReverseCommand; - let new_cmd = ReverseCommand::new(); - self.command_line.push_info(&new_cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + if self.tabs[i].scene.selected.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let selection = SelectObjectsCommand::new("REVERSE"); + self.command_line.push_info(&selection.prompt()); + self.tabs[i].active_cmd = Some(Box::new(selection)); + } else { + let replacements = self.tabs[i].scene.selected_entities().into_iter() + .filter(|(handle, _)| !self.tabs[i].scene.is_layer_locked(*handle)) + .filter_map(|(handle, entity)| ReverseCommand::reversed(entity) + .map(|reversed| (handle, vec![reversed]))) + .collect::>(); + if !replacements.is_empty() { + return Some(self.apply_cmd_result(crate::command::CmdResult::ReplaceMany( + replacements, Vec::new(), + ))); + } + } } "MEASUREGEOM" => { @@ -881,7 +895,7 @@ impl OpenCADStudio { SplineCommand::control_vertices() } else { SplineCommand::new() - }; + }.with_document(&self.tabs[i].scene.document); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } @@ -963,6 +977,193 @@ impl OpenCADStudio { } } + // ── Persistent constraints ──────────────────────────────────── + "HCONSTRAINT" | "VCONSTRAINT" | "FXCONSTRAINT" => { + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let sel = SelectObjectsCommand::new(cmd); + self.command_line.push_info(&sel.prompt()); + self.tabs[i].active_cmd = Some(Box::new(sel)); + } else if handles.len() != 1 { + self.command_line + .push_output("Select exactly one entity, then run this constraint again."); + } else { + use crate::command::CmdResult; + use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; + let (kind, label) = match cmd { + "HCONSTRAINT" => (ConstraintKind::Horizontal, "Horizontal constraint"), + "VCONSTRAINT" => (ConstraintKind::Vertical, "Vertical constraint"), + _ => (ConstraintKind::Fixed, "Fixed constraint"), + }; + return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { + kind, + refs: vec![SketchRef::whole(handles[0])], + driving_param: None, + label, + })); + } + } + + "CCONSTRAINT" => { + use crate::modules::draw::constrain::CoincidentConstraintCommand; + let new_cmd = CoincidentConstraintCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + "EDCONSTRAINT" => { + use crate::modules::draw::constrain::EqualDistanceConstraintCommand; + let new_cmd = EqualDistanceConstraintCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + "PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" | "NRCONSTRAINT" => { + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let sel = SelectObjectsCommand::new(cmd); + self.command_line.push_info(&sel.prompt()); + self.tabs[i].active_cmd = Some(Box::new(sel)); + } else if handles.len() != 2 { + self.command_line.push_output( + "Select exactly two entities (first = reference, second = the one that moves), then run this constraint again.", + ); + } else { + use crate::command::CmdResult; + use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; + let (kind, label) = match cmd { + "PCONSTRAINT" => (ConstraintKind::Parallel, "Parallel 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 { + kind, + refs: vec![SketchRef::whole(handles[0]), SketchRef::whole(handles[1])], + driving_param: None, + label, + })); + } + } + + "NCONSTRAINT" => { + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let sel = SelectObjectsCommand::new(cmd); + self.command_line.push_info(&sel.prompt()); + self.tabs[i].active_cmd = Some(Box::new(sel)); + } else if handles.len() != 2 { + self.command_line.push_output("Select exactly two circles/arcs, then run this constraint again."); + } else { + use crate::command::CmdResult; + use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; + return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Concentric, + refs: vec![SketchRef::center(handles[0]), SketchRef::center(handles[1])], + driving_param: None, + label: "Concentric constraint", + })); + } + } + + "SYCONSTRAINT" => { + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let sel = SelectObjectsCommand::new(cmd); + self.command_line.push_info(&sel.prompt()); + self.tabs[i].active_cmd = Some(Box::new(sel)); + } else if handles.len() != 3 { + self.command_line.push_output( + "Select exactly three entities (two circles/arcs, then the mirror line), then run this constraint again.", + ); + } else { + use crate::command::CmdResult; + use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; + return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Symmetric, + refs: vec![SketchRef::center(handles[0]), SketchRef::center(handles[1]), SketchRef::whole(handles[2])], + driving_param: None, + label: "Symmetric constraint", + })); + } + } + + "CPCONSTRAINT" | "MPCONSTRAINT" | "OCCONSTRAINT" => { + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let sel = SelectObjectsCommand::new(cmd); + self.command_line.push_info(&sel.prompt()); + self.tabs[i].active_cmd = Some(Box::new(sel)); + } else if handles.len() != 1 { + self.command_line.push_output( + "Select exactly one entity (the circle or line the point should attach to), then run this constraint again.", + ); + } else { + use crate::modules::draw::constrain::PointOnEntityConstraintCommand; + use crate::scene::sketch_constraints::ConstraintKind; + let (name, kind, label) = match cmd { + "CPCONSTRAINT" => ("CPCONSTRAINT", ConstraintKind::CenterPoint, "Center point constraint"), + "MPCONSTRAINT" => ("MPCONSTRAINT", ConstraintKind::Midpoint, "Midpoint constraint"), + _ => ("OCCONSTRAINT", ConstraintKind::PointOnCurve, "Point on curve constraint"), + }; + let new_cmd = PointOnEntityConstraintCommand::new(name, kind, handles[0], label); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + } + + "DCONSTRAINT" => { + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let sel = SelectObjectsCommand::new(cmd); + self.command_line.push_info(&sel.prompt()); + self.tabs[i].active_cmd = Some(Box::new(sel)); + } else if handles.len() != 1 { + self.command_line + .push_output("Select exactly one line or circle, then run this constraint again."); + } else { + use crate::modules::draw::constrain::DistanceConstraintCommand; + match DistanceConstraintCommand::new(&self.tabs[i].scene, handles[0]) { + Some(new_cmd) => { + 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."), + } + } + } + + "ACONSTRAINT" => { + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let sel = SelectObjectsCommand::new(cmd); + self.command_line.push_info(&sel.prompt()); + self.tabs[i].active_cmd = Some(Box::new(sel)); + } else if handles.len() != 2 { + self.command_line.push_output( + "Select exactly two lines (first = reference, second = the one that rotates), then run this constraint again.", + ); + } else { + use crate::modules::draw::constrain::AngleConstraintCommand; + match AngleConstraintCommand::new(&self.tabs[i].scene, handles[0], handles[1]) { + Some(new_cmd) => { + 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."), + } + } + } + // ── Model commands (3D primitives) ───────────────────────────── "CYLINDER" => { use crate::modules::model::cylinder_cmd::CylinderCommand; @@ -1114,8 +1315,7 @@ impl OpenCADStudio { return Some(self.fit_spline()); } - // REGION — convert selected closed boundaries (closed polylines / - // circles) into Region entities (one wire loop each). + // REGION — convert exact closed planar profiles into regions. "REGION" | "REG" => { if self.tabs[i].scene.selected_entities().is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -1129,12 +1329,7 @@ impl OpenCADStudio { let mut regions = Vec::new(); let mut sources = Vec::new(); for (handle, e) in self.tabs[i].scene.selected_entities().iter() { - let supported = matches!( - e, - acadrust::EntityType::LwPolyline(pl) - if pl.is_closed && pl.vertices.len() >= 3 - ) || matches!(e, acadrust::EntityType::Circle(_)); - if supported { + if !matches!(e, acadrust::EntityType::Region(_)) { let Some((plane, loops, true)) = crate::scene::model::presspull_model::profile_geometry(e) else { @@ -1154,9 +1349,55 @@ impl OpenCADStudio { 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() + .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 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.", + ); + } if regions.is_empty() { - self.command_line - .push_error(crate::t!("REGION: select closed polylines or circles.").as_ref()); + if !open_plane_rejected { + self.command_line.push_error( + "REGION: select closed planar profiles or connected coplanar edges.", + ); + } } else { self.push_undo_snapshot(i, "REGION"); let count = regions.len(); @@ -1171,6 +1412,8 @@ impl OpenCADStudio { created.push(handle); } if self.delete_objects != 0 { + sources.sort_by_key(|handle| handle.value()); + sources.dedup(); self.tabs[i].scene.erase_entities(&sources); self.refresh_properties(); } diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 08f920696..35b7e3739 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -218,7 +218,10 @@ impl OpenCADStudio { self.apply_cmd_result(crate::command::CmdResult::JoinEntities(selected)); return Some(task); } - let cmd = JoinCommand::new(); + let mut cmd = JoinCommand::new(); + if let Some(handle) = selected.first() { + if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() { cmd = cmd.with_source(*handle, entity); } + } self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } @@ -294,6 +297,7 @@ impl OpenCADStudio { header.surface_u_density, header.surface_v_density, ) + .with_entities(self.tabs[i].scene.document.entities().cloned()) .with_preselection(&preselected); self.command_line.push_info(&cmd_obj.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); @@ -329,7 +333,17 @@ impl OpenCADStudio { "SPLINEDIT" => { use crate::modules::draw::modify::splinedit::SplineditCommand; - let cmd_obj = SplineditCommand::new(); + let mut cmd_obj = SplineditCommand::new().with_delete_source(self.delete_objects != 0); + let selected: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); + if let [handle] = selected.as_slice() { + if let Some(entity @ acadrust::EntityType::Spline(_)) = + self.tabs[i].scene.document.get_entity(*handle).cloned() + { + if self.reject_locked_edit(i, *handle) { return Some(Task::none()); } + cmd_obj.inject_picked_entity(entity); + cmd_obj.on_entity_pick(*handle, glam::DVec3::ZERO); + } + } self.command_line.push_info(&cmd_obj.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index e3650b5d1..7f00166e3 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -322,6 +322,7 @@ inventory::submit!(crate::command::CommandRegistration { "CLEANSCREEN", "CUI", "DSETTINGS", + "PARAMETERS", "GRID", "ISODRAFT", "ISOPLANE", @@ -598,6 +599,10 @@ inventory::submit!(crate::command::CommandRegistration { "DIMSTYLE", "DONATE", "DRAWORDER", + "DRAWORDER_FRONT", + "DRAWORDER_BACK", + "DRAWORDER_ABOVE", + "DRAWORDER_UNDER", "DWGPROP", "DWGPROPS", "EATTEXT", diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index b99da19b4..9f43c15ad 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -645,98 +645,221 @@ impl OpenCADStudio { // ── SETBYLAYER — clear color/linetype/lineweight overrides ──── // Resets the selected entities' direct property overrides back to // ByLayer so they follow their layer again. + "SETBYLAYERMODE" => { + let command = crate::modules::draw::modify::setbylayer::ModeCommand; + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + value if value.starts_with("SETBYLAYERMODE ") => { + if let Ok(mode) = value.trim_start_matches("SETBYLAYERMODE ").trim().parse::() { + crate::modules::draw::modify::setbylayer::set_mode(mode); + } else { self.command_line.push_error("SETBYLAYERMODE requires an integer from 0 to 255."); } + } "SETBYLAYER" => { - let handles: Vec<_> = self.tabs[i] + let command = crate::modules::draw::modify::setbylayer::SetByLayerCommand::new( + self.tabs[i].scene.selected.iter().copied().collect(), + ); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + cmd if cmd.starts_with("SETBYLAYER_APPLY ") => { + let flags: Vec<_> = cmd.split_whitespace().skip(1).collect(); + if flags.len() != 2 || flags.iter().any(|flag| !matches!(*flag, "0" | "1")) { + return Some(Task::none()); + } + let mask = crate::modules::draw::modify::setbylayer::mode(); + let change_byblock = flags[0] == "1"; + let include_blocks = flags[1] == "1"; + let mut handles: Vec<_> = self.tabs[i] .scene .selected_entities() .into_iter() .map(|(h, _)| h) .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); + if include_blocks { + let mut visited: std::collections::HashSet<_> = handles.iter().copied().collect(); + let mut index = 0; + while index < handles.len() { + let children = match self.tabs[i].scene.document.get_entity(handles[index]) { + Some(acadrust::EntityType::Insert(insert)) => self.tabs[i].scene.document + .block_records.get(&insert.block_name) + .map(|block| block.entity_handles.clone()).unwrap_or_default(), + _ => Vec::new(), + }; + for child in children { + if visited.insert(child) && !self.tabs[i].scene.is_layer_locked(child) { + handles.push(child); + } + } + index += 1; + } + } if handles.is_empty() { self.command_line .push_error(crate::t!("SETBYLAYER: select entities first.").as_ref()); } else { + let has_changes = handles.iter().any(|handle| { + self.tabs[i].scene.document.get_entity(*handle).is_some_and(|entity| { + let mut common = entity.common().clone(); + crate::modules::draw::modify::setbylayer::apply_mask( + &mut common, mask, change_byblock, + ) + }) + }); + if !has_changes { + self.command_line.push_output( + crate::t!("SETBYLAYER: no properties required changes.").as_ref(), + ); + return Some(Task::none()); + } self.push_undo_snapshot(i, "SETBYLAYER"); - let mut changed = 0usize; + let mut changed = Vec::new(); for handle in &handles { if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*handle) { - let common = entity.common_mut(); - common.color = acadrust::types::Color::ByLayer; - common.color_name = None; - common.color_book_handle = None; - common.linetype = "ByLayer".to_string(); - common.line_weight = acadrust::types::LineWeight::ByLayer; - changed += 1; + if crate::modules::draw::modify::setbylayer::apply_mask( + entity.common_mut(), mask, change_byblock, + ) { + changed.push(*handle); + } } } self.tabs[i].dirty = true; - let changes: Vec<_> = handles - .into_iter() + let changes: Vec<_> = changed + .iter() + .copied() .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); self.tabs[i].scene.bump_entities(&changes); + self.refresh_properties(); self.command_line.push_output(crate::tf!( - "SETBYLAYER: reset {changed} entity/entities to ByLayer." + "SETBYLAYER: reset {} entity/entities to ByLayer.", + changed.len() ).as_ref()); } } - // ── OVERKILL — delete duplicate (identical) objects ────────── - // Removes objects that are identical in geometry AND properties to - // another object (compared with the handle ignored). Operates on - // the current selection, or the whole drawing when nothing is - // selected. Conservative: only exact duplicates are removed. - "OVERKILL" => { - use acadrust::Handle; - let selected: std::collections::HashSet = self.tabs[i] - .scene - .selected_entities() - .into_iter() - .map(|(h, _)| h.value()) - .collect(); - // Capture (handle, type-name, handle-normalized clone) for each - // candidate while the document is borrowed immutably. - let candidates: Vec<(Handle, String, acadrust::EntityType)> = self.tabs[i] - .scene - .document - .entities() - .filter(|e| { - (selected.is_empty() || selected.contains(&e.common().handle.value())) - && !self.tabs[i].scene.is_layer_locked(e.common().handle) - }) - .map(|e| { - let key = crate::entities::names::dxf_name(e).to_string(); - let mut norm = e.clone(); - norm.common_mut().handle = Handle::NULL; - (e.common().handle, key, norm) - }) - .collect(); - // Bucket by (type, layer) so only like objects are compared. - let mut kept: Vec<(String, acadrust::EntityType)> = Vec::new(); - let mut dups: Vec = Vec::new(); - for (h, key, norm) in &candidates { - let bucket = format!("{key}\u{0}{}", norm.common().layer); - if kept.iter().any(|(b, e)| b == &bucket && e == norm) { - dups.push(*h); - } else { - kept.push((bucket, norm.clone())); + // OVERKILL gathers objects, exposes cleanup settings, then applies one undo group. + "OVERKILL" | "-OVERKILL" => { + let handles=self.tabs[i].scene.selected_entities().iter().map(|(h,_)|*h).collect(); + let command=crate::modules::draw::modify::overkill::OverkillCommand::new(handles); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd=Some(Box::new(command)); + } + value if value.starts_with("OVERKILL_APPLY ") => { + use crate::modules::draw::modify::overkill::{normalized,optimize}; + let values:Vec<_>=value.split_whitespace().skip(1).collect(); + if values.len()!=6 { return Some(self.finish_dispatch(cmd)); } + let tolerance=values[0].parse::().unwrap_or(1e-6); + let ignore=values[1].parse::().unwrap_or(0); + let optimize_plines=values[2]=="1"; + let overlap=values[3]=="1"; + let end_to_end=values[4]=="1"; + let preserve_associative=values[5]=="1"; + let mut candidates:Vec<_>=self.tabs[i].scene.selected_entities().into_iter() + .filter(|(h,e)|!self.tabs[i].scene.is_layer_locked(*h) + && (!preserve_associative||e.common().reactors.is_empty())) + .map(|(h,e)|(h,e.clone())).collect(); + candidates.sort_by_key(|(h,_)|h.value()); + let mut changed=std::collections::HashSet::new(); + if optimize_plines { for (h,e) in &mut candidates { + let before=e.clone(); optimize(e,tolerance); if *e!=before {changed.insert(*h);} + } } + let mut removed=std::collections::HashSet::new(); + // Iterate to a fixed point: a bridge can join two previously disjoint intervals. + loop { + let mut progress=false; + for a in 0..candidates.len() { + if removed.contains(&candidates[a].0) {continue;} + for b in a+1..candidates.len() { + if removed.contains(&candidates[b].0) {continue;} + let left=normalized(&candidates[a].1,ignore); + let right=normalized(&candidates[b].1,ignore); + if left==right { + removed.insert(candidates[b].0);progress=true;continue; + } + if overlap { + let contained = |circle: &acadrust::entities::Circle, arc: &acadrust::entities::Arc| { + circle.common == arc.common && circle.thickness == arc.thickness + && cadkernel::space::arc_union::circle_contains_arc( + [circle.center.x,circle.center.y,circle.center.z], + [circle.normal.x,circle.normal.y,circle.normal.z],circle.radius, + cadkernel::space::arc_union::CircularArc { center:[arc.center.x,arc.center.y,arc.center.z], + normal:[arc.normal.x,arc.normal.y,arc.normal.z],radius:arc.radius,start:arc.start_angle,end:arc.end_angle }) + }; + match (&left,&right) { + (acadrust::EntityType::Circle(circle),acadrust::EntityType::Arc(arc)) if contained(circle,arc) => { + removed.insert(candidates[b].0);progress=true;continue; + } + (acadrust::EntityType::Arc(arc),acadrust::EntityType::Circle(circle)) if contained(circle,arc) => { + removed.insert(candidates[a].0);progress=true;break; + } + _ => {}, + } + } + if let (acadrust::EntityType::Arc(l),acadrust::EntityType::Arc(r))=(&left,&right) { + use cadkernel::space::arc_union::{CircularArc,ArcUnionKind,circular_arc_union}; + if l.common!=r.common || l.thickness!=r.thickness {continue;} + let arc=|v:&acadrust::entities::Arc|CircularArc{center:[v.center.x,v.center.y,v.center.z],normal:[v.normal.x,v.normal.y,v.normal.z],radius:v.radius,start:v.start_angle,end:v.end_angle}; + let Some(union)=circular_arc_union(arc(l),arc(r),tolerance) else {continue;}; + let allowed=match union.kind {ArcUnionKind::Duplicate=>true,ArcUnionKind::Overlap=>overlap,ArcUnionKind::EndToEnd=>end_to_end}; + if !allowed {continue;} + if let acadrust::EntityType::Arc(source)=&candidates[a].1 { + let replacement=if union.full_circle { + let mut circle=acadrust::entities::Circle::new(); + circle.common=source.common.clone();circle.center=source.center.clone();circle.normal=source.normal.clone();circle.radius=source.radius;circle.thickness=source.thickness; + acadrust::EntityType::Circle(circle) + } else { + let mut arc=source.clone();arc.start_angle=union.start;arc.end_angle=union.end;acadrust::EntityType::Arc(arc) + }; + candidates[a].1=replacement; + } + changed.insert(candidates[a].0);removed.insert(candidates[b].0);progress=true; + continue; + } + let (acadrust::EntityType::Line(l),acadrust::EntityType::Line(r))=(&left,&right) + else {continue;}; + if l.common!=r.common||l.thickness!=r.thickness||l.normal!=r.normal {continue;} + let point=|p:acadrust::types::Vector3|[p.x,p.y,p.z]; + let Some(union)=cadkernel::space::line_union( + [point(l.start),point(l.end)],[point(r.start),point(r.end)],tolerance) + else {continue;}; + let allowed=match union.kind { + cadkernel::space::LineUnionKind::Duplicate=>true, + cadkernel::space::LineUnionKind::Overlap=>overlap, + cadkernel::space::LineUnionKind::EndToEnd=>end_to_end, + }; + if !allowed {continue;} + if let acadrust::EntityType::Line(line)=&mut candidates[a].1 { + line.start=acadrust::types::Vector3::new(union.start[0],union.start[1],union.start[2]); + line.end=acadrust::types::Vector3::new(union.end[0],union.end[1],union.end[2]); + } + changed.insert(candidates[a].0);removed.insert(candidates[b].0);progress=true; + } } + if !progress {break;} } - if dups.is_empty() { - self.command_line - .push_output(crate::t!("OVERKILL: no duplicate objects found.").as_ref()); + if !changed.is_empty()||!removed.is_empty() { + self.push_undo_snapshot(i,"OVERKILL"); + let updates:Vec<_>=candidates.into_iter().filter(|(h,_)|changed.contains(h)&&!removed.contains(h)).collect(); + let updated_count = updates.len(); + let mut changes = Vec::new(); + for (handle, entity) in updates { + if let Some(target) = self.tabs[i].scene.document.get_entity_mut(handle) { + *target = entity; + changes.push((handle, crate::scene::ChangeKind::Modified)); + } + } + self.tabs[i].scene.bump_entities(&changes); + let mut handles:Vec<_>=removed.iter().copied().collect(); + handles.sort_by_key(|handle| handle.value()); + self.tabs[i].scene.erase_entities(&handles); + self.tabs[i].dirty=true;self.refresh_properties(); + self.command_line.push_output(&format!("OVERKILL: removed {} objects; updated {} objects.",removed.len(),updated_count)); } else { - let n = dups.len(); - self.push_undo_snapshot(i, "OVERKILL"); - self.tabs[i].scene.erase_entities(&dups); - self.tabs[i].dirty = true; - self.refresh_properties(); - self.command_line - .push_output(crate::tf!("OVERKILL: deleted {n} duplicate object(s).").as_ref()); + self.command_line.push_output("OVERKILL: no objects required changes."); } } - // ── PICKADD / PICKDRAG — selection UX (#226, app settings) ─── // Bare form reports; ` 0|1` sets and persists. Defaults keep // today's behaviour (PICKADD 1, PICKDRAG 0). @@ -2983,6 +3106,21 @@ mod tests { app } + #[test] + fn selection_commands_gather_instead_of_using_the_whole_drawing() { + for command in ["SETBYLAYER", "OVERKILL"] { + let mut app = fresh_app(); + let _ = app.run_command_line(command); + assert!( + app.tabs[app.active_tab] + .active_cmd + .as_ref() + .is_some_and(|active| active.is_selection_gathering()), + "{command} should gather an explicit selection" + ); + } + } + #[test] fn test_cad_selection_and_model_space_sysvars() { let mut app = fresh_app(); @@ -3195,4 +3333,3 @@ mod scale_validation_tests { } } } - diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 2ac432f68..073b674b0 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -1,5 +1,62 @@ use super::*; +// Sort tables belong to the block's extension dictionary. Reserve real handles +// and reconnect older unattached tables without losing their existing entries. +fn ensure_draw_order_table( + doc: &mut acadrust::CadDocument, + block: acadrust::Handle, +) -> acadrust::Handle { + use acadrust::objects::{Dictionary, ObjectType, SortEntitiesTable}; + + // Older commands inserted objects using the unreserved next-handle value. + // Repair the allocator floor before creating anything beside those objects. + if let Some(maximum) = doc.objects.keys().map(|handle| handle.value()).max() { + doc.header.handle_seed = doc.header.handle_seed.max(maximum.saturating_add(1)); + } + let dictionary = doc.extension_dictionary_handle(block).filter(|handle| { + matches!(doc.objects.get(handle), Some(ObjectType::Dictionary(_))) + }); + let named_table = dictionary.and_then(|handle| match doc.objects.get(&handle) { + Some(ObjectType::Dictionary(value)) => value.get(SortEntitiesTable::DICTIONARY_KEY), + _ => None, + }).filter(|handle| matches!(doc.objects.get(handle), + Some(ObjectType::SortEntitiesTable(table)) if table.block_owner_handle == block)); + let existing = named_table.or_else(|| doc.objects.iter().find_map(|(handle, object)| { + match object { + ObjectType::SortEntitiesTable(table) if table.block_owner_handle == block => Some(*handle), + _ => None, + } + })); + let dictionary = dictionary.unwrap_or_else(|| { + let handle = doc.allocate_handle(); + let mut value = Dictionary::new(); + value.handle = handle; + value.owner = block; + value.hard_owner = true; + doc.objects.insert(handle, ObjectType::Dictionary(value)); + handle + }); + let handle = existing.unwrap_or_else(|| { + let handle = doc.allocate_handle(); + let mut table = SortEntitiesTable::for_block(block); + table.handle = handle; + doc.objects.insert(handle, ObjectType::SortEntitiesTable(table)); + handle + }); + if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&handle) { + table.owner_handle = dictionary; + } + if let Some(ObjectType::Dictionary(value)) = doc.objects.get_mut(&dictionary) { + if let Some((_, entry)) = value.entries.iter_mut() + .find(|(name, _)| name.eq_ignore_ascii_case(SortEntitiesTable::DICTIONARY_KEY)) { + *entry = handle; + } else { + value.add_entry(SortEntitiesTable::DICTIONARY_KEY, handle); + } + } + handle +} + impl OpenCADStudio { pub(crate) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option> { match cmd { @@ -253,6 +310,10 @@ impl OpenCADStudio { return Some(Task::done(Message::OptionsOpen)); } + "PARAMETERS" => { + return Some(Task::done(Message::NamedParametersOpen)); + } + // CUIEXPORT — write the keyboard-shortcut customizations // (the drawing-independent CUI data) to a plain "KEY COMMAND" file. "CUIEXPORT" => { @@ -817,17 +878,15 @@ impl OpenCADStudio { // HATCHTOBACK ÔÇö move every hatch object in the active space to the back of the draw order. "HATCHTOBACK" => { - use acadrust::objects::{ObjectType, SortEntitiesTable}; + use acadrust::objects::ObjectType; let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); let doc_ref = &self.tabs[i].scene.document; - // 1. Single scan over objects to find existing SortEntitiesTable handle & overrides. - let mut existing_table_handle = None; + // 1. Read existing SortEntitiesTable overrides. let mut overrides: Option> = None; - for (h, obj) in &doc_ref.objects { + for obj in doc_ref.objects.values() { if let ObjectType::SortEntitiesTable(t) = obj { if t.block_owner_handle == block_handle { - existing_table_handle = Some(*h); if !t.is_empty() { overrides = Some( t.entries() @@ -894,24 +953,9 @@ impl OpenCADStudio { &locked_layers, ); - // 4. Ultra-fast targeted Delta Undo (snapshots ONLY the SortEntitiesTable, zero full-drawing clone). - let pending_delta = self.begin_undo(i, "DRAWORDER", hatches_to_move.len(), true); - - // 5. Update or insert SortEntitiesTable directly. - let table_before = existing_table_handle - .and_then(|h| self.tabs[i].scene.document.objects.get(&h).cloned()); - let th = existing_table_handle.unwrap_or_else(|| { - let nh = acadrust::Handle::new(self.tabs[i].scene.document.next_handle()); - let mut table = SortEntitiesTable::for_block(block_handle); - table.handle = nh; - self.tabs[i] - .scene - .document - .objects - .insert(nh, ObjectType::SortEntitiesTable(table)); - nh - }); - self.tabs[i].scene.record_undo_object_before(th, table_before); + // Dictionary ownership and handle allocation are part of this undo step. + let pending_delta = self.begin_undo(i, "DRAWORDER", hatches_to_move.len(), false); + let th = ensure_draw_order_table(&mut self.tabs[i].scene.document, block_handle); if let Some(ObjectType::SortEntitiesTable(table)) = self.tabs[i].scene.document.objects.get_mut(&th) @@ -941,6 +985,37 @@ impl OpenCADStudio { return Some(Task::none()); } + "DRAWORDER_FRONT" | "DRAWORDER_BACK" | "DRAWORDER_ABOVE" | "DRAWORDER_UNDER" => { + let selected: Vec = self.tabs[i] + .scene + .selected_entities() + .iter() + .map(|(h, _)| *h) + .collect(); + if selected.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let selection = SelectObjectsCommand::plain("DRAWORDER", cmd); + self.command_line.push_info(&selection.prompt()); + self.tabs[i].active_cmd = Some(Box::new(selection)); + } else if matches!(cmd, "DRAWORDER_ABOVE" | "DRAWORDER_UNDER") { + let command = DrawOrderCommand::for_reference_pick( + selected, + cmd == "DRAWORDER_ABOVE", + ); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } else { + let primitive = if cmd == "DRAWORDER_FRONT" { + "DRAWORDER FRONT" + } else { + "DRAWORDER BACK" + }; + return Some(self.apply_cmd_result(crate::command::CmdResult::Relaunch( + primitive.to_string(), + selected, + ))); + } + } "DRAWORDER" => { let selected: Vec = self.tabs[i] .scene @@ -953,7 +1028,7 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("DRAWORDER ") => { - use acadrust::objects::{ObjectType, SortEntitiesTable}; + use acadrust::objects::ObjectType; let parts: Vec<&str> = cmd.split_whitespace().collect(); let option = parts.get(1).unwrap_or(&"").to_uppercase(); let i = self.active_tab; @@ -968,25 +1043,29 @@ impl OpenCADStudio { self.command_line .push_error(crate::t!("DRAWORDER: select entities first.").as_ref()); } else { - // Parse relative target handle for ABOVE/UNDER. - let relative_target: Option<(bool, acadrust::Handle)> = match option.as_str() { - "A" | "ABOVE" => { - let h_val = parts.get(2).and_then(|s| u64::from_str_radix(s, 16).ok()); - h_val.map(|v| (true, acadrust::Handle::new(v))) - } - "U" | "UNDER" | "BELOW" => { - let h_val = parts.get(2).and_then(|s| u64::from_str_radix(s, 16).ok()); - h_val.map(|v| (false, acadrust::Handle::new(v))) - } + let relative_above = match option.as_str() { + "A" | "ABOVE" => Some(true), + "U" | "UNDER" | "BELOW" => Some(false), _ => None, }; + let references: Vec<_> = parts.iter().skip(2).filter_map(|text| { + u64::from_str_radix(text.trim_start_matches("0x").trim_start_matches("0X"), 16) + .ok().map(acadrust::Handle::new) + }).filter(|handle| !selected.contains(handle)).collect(); + let relative_assignments = relative_above.and_then(|above| { + assign_relative_group_keys( + &self.tabs[i].scene.document, + self.tabs[i].scene.current_layout_block_handle_pub(), + &selected, &references, above, + ) + }); let to_front_opt = match option.as_str() { "F" | "FRONT" => Some(true), "B" | "BACK" => Some(false), _ => None, }; - if relative_target.is_some() || to_front_opt.is_some() { + if relative_assignments.is_some() || to_front_opt.is_some() { self.push_undo_snapshot(i, "DRAWORDER"); let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); @@ -1063,63 +1142,14 @@ impl OpenCADStudio { }; let doc = &mut self.tabs[i].scene.document; - let table_handle = doc.objects.iter().find_map(|(h, obj)| { - if let ObjectType::SortEntitiesTable(t) = obj { - if t.block_owner_handle == block_handle { - Some(*h) - } else { - None - } - } else { - None - } - }); - let th = table_handle.unwrap_or_else(|| { - let nh = acadrust::Handle::new(doc.next_handle()); - let mut table = SortEntitiesTable::for_block(block_handle); - table.handle = nh; - doc.objects.insert(nh, ObjectType::SortEntitiesTable(table)); - nh - }); + let th = ensure_draw_order_table(doc, block_handle); if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&th) { - if let Some((above, target)) = relative_target { - // move_above/move_below recompute target±1 per - // call, so looping them over N selected entities - // ties the whole group onto one key. Read the - // reference key once and hand out per-index - // keys instead, keeping the moved entities a - // distinct, selection-ordered block adjacent to - // the reference. - let target_sort = match table.get_sort_handle(target) { - Some(h) => h.value(), - None => { - // A reference object that was never - // reordered isn't in the table yet; - // seed it with its own handle as the - // implicit sort key. - table.add_entry(target, target); - table - .get_sort_handle(target) - .map_or(target.value(), |h| h.value()) - } - }; - for (k, h) in selected.iter().enumerate() { - let offset = 1 + k as u64; - let sort = if above { - target_sort.saturating_add(offset) - } else { - target_sort.saturating_sub(offset).max(1) - }; - table.add_entry(*h, acadrust::Handle::new(sort)); + if let Some(assignments) = &relative_assignments { + for (handle, sort) in assignments { + table.add_entry(*handle, acadrust::Handle::new(*sort)); } - let rel = if above { "above" } else { "below" }; - self.command_line.push_info(crate::tf!( - "DRAWORDER: moved {} entities {} {:x}.", - selected.len(), - rel, - target.value() - ).as_ref()); + self.command_line.push_info(crate::t!("DRAWORDER: selection reordered relative to reference objects.").as_ref()); } else if let Some(to_front) = to_front_opt { if to_front { let (_, max_eff) = fb_baseline.unwrap_or((1, 0)); @@ -1250,11 +1280,12 @@ enum DrawOrderStep { /// - `Front` / `F`: moves selection to front. /// - `Back` / `B` / Enter: moves selection to back. /// - `Above` / `A` / `Under` / `U`: advances to reference object pick. -/// 3. Reference object pick: user can click the reference entity in the viewport -/// or type its hex handle on the command line. +/// 3. Gather reference objects, excluding the moved selection; Enter applies. +/// Typed hexadecimal handles may also be accumulated before confirming. pub(crate) struct DrawOrderCommand { selected: Vec, step: DrawOrderStep, + references: Vec, } impl DrawOrderCommand { @@ -1264,13 +1295,14 @@ impl DrawOrderCommand { } else { DrawOrderStep::ChooseVerb }; - Self { selected, step } + Self { selected, step, references: Vec::new() } } pub(crate) fn for_reference_pick(selected: Vec, above: bool) -> Self { Self { selected, step: DrawOrderStep::PickReference { above }, + references: Vec::new(), } } } @@ -1289,10 +1321,10 @@ impl CadCommand for DrawOrderCommand { crate::t!("DRAWORDER [Above / Under / Front / Back] :").into_owned() } DrawOrderStep::PickReference { above: true } => { - crate::t!("DRAWORDER Select reference object (move selection above):").into_owned() + format!("DRAWORDER Select reference objects to move above ({} selected, Enter when done):", self.references.len()) } DrawOrderStep::PickReference { above: false } => { - crate::t!("DRAWORDER Select reference object (move selection under):").into_owned() + format!("DRAWORDER Select reference objects to move under ({} selected, Enter when done):", self.references.len()) } } } @@ -1310,7 +1342,7 @@ impl CadCommand for DrawOrderCommand { } fn input_kind(&self) -> crate::command::InputKind { - if matches!(self.step, DrawOrderStep::SelectObjects) { + if matches!(self.step, DrawOrderStep::SelectObjects | DrawOrderStep::PickReference { .. }) { crate::command::InputKind::Point } else { crate::command::InputKind::SingleToken @@ -1318,11 +1350,15 @@ impl CadCommand for DrawOrderCommand { } fn is_selection_gathering(&self) -> bool { - matches!(self.step, DrawOrderStep::SelectObjects) + matches!(self.step, DrawOrderStep::SelectObjects | DrawOrderStep::PickReference { .. }) } fn on_selection_complete(&mut self, handles: Vec) -> crate::command::CmdResult { - self.selected = handles; + if matches!(self.step, DrawOrderStep::PickReference { .. }) { + self.references = handles.into_iter().filter(|handle| !self.selected.contains(handle)).collect(); + } else { + self.selected = handles; + } crate::command::CmdResult::NeedPoint } @@ -1341,7 +1377,12 @@ impl CadCommand for DrawOrderCommand { let handles = std::mem::take(&mut self.selected); crate::command::CmdResult::Relaunch("DRAWORDER BACK".into(), handles) } - DrawOrderStep::PickReference { .. } => crate::command::CmdResult::Cancel, + DrawOrderStep::PickReference { above } => { + if self.references.is_empty() { return crate::command::CmdResult::Cancel; } + let option = if above { "ABOVE" } else { "UNDER" }; + let references = self.references.iter().map(|handle| format!("{:x}", handle.value())).collect::>().join(" "); + crate::command::CmdResult::Relaunch(format!("DRAWORDER {option} {references}"), std::mem::take(&mut self.selected)) + } } } @@ -1374,47 +1415,63 @@ impl CadCommand for DrawOrderCommand { _ => Some(crate::command::CmdResult::NeedPoint), } } - DrawOrderStep::PickReference { above } => { - let hex_str = t.trim_start_matches("0x").trim_start_matches("0X"); - if let Ok(val) = u64::from_str_radix(hex_str, 16) { - let opt = if above { "A" } else { "U" }; - let cmd = format!("DRAWORDER {} {:x}", opt, val); - let handles = std::mem::take(&mut self.selected); - Some(crate::command::CmdResult::Relaunch(cmd, handles)) - } else { - Some(crate::command::CmdResult::NeedPoint) + DrawOrderStep::PickReference { .. } => { + for text in t.split_whitespace() { + let hex = text.trim_start_matches("0x").trim_start_matches("0X"); + if let Ok(value) = u64::from_str_radix(hex, 16) { + let handle = acadrust::Handle::new(value); + if !handle.is_null() && !self.selected.contains(&handle) && !self.references.contains(&handle) { + self.references.push(handle); + } + } } + Some(crate::command::CmdResult::NeedPoint) } } } - fn needs_entity_pick(&self) -> bool { - matches!(self.step, DrawOrderStep::PickReference { .. }) - } - - fn on_entity_pick( - &mut self, - handle: acadrust::Handle, - _pt: glam::DVec3, - ) -> crate::command::CmdResult { - if handle.is_null() { - return crate::command::CmdResult::NeedPoint; - } - if let DrawOrderStep::PickReference { above } = self.step { - let opt = if above { "A" } else { "U" }; - let cmd = format!("DRAWORDER {} {:x}", opt, handle.value()); - let handles = std::mem::take(&mut self.selected); - crate::command::CmdResult::Relaunch(cmd, handles) - } else { - crate::command::CmdResult::NeedPoint + fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: glam::DVec3) -> crate::command::CmdResult { + if matches!(self.step, DrawOrderStep::PickReference { .. }) && !handle.is_null() + && !self.selected.contains(&handle) && !self.references.contains(&handle) { + self.references.push(handle); } + crate::command::CmdResult::NeedPoint } - fn on_point(&mut self, _pt: glam::DVec3) -> crate::command::CmdResult { crate::command::CmdResult::NeedPoint } } +/// Insert the selected block next to the extremal reference in effective draw +/// order. Renumber the ordered siblings to avoid ties or saturated sort keys. +fn assign_relative_group_keys( + document: &acadrust::CadDocument, + block: acadrust::Handle, + selected: &[acadrust::Handle], + references: &[acadrust::Handle], + above: bool, +) -> Option> { + use acadrust::objects::ObjectType; + let overrides: std::collections::HashMap<_, _> = document.objects.values().find_map(|object| { + if let ObjectType::SortEntitiesTable(table) = object { + (table.block_owner_handle == block).then(|| table.entries().map(|entry| (entry.entity_handle, entry.sort_handle.value())).collect()) + } else { None } + }).unwrap_or_default(); + let mut ordered: Vec<_> = document.entities().filter(|entity| { + let owner = entity.common().owner_handle; + owner == block || owner.is_null() + }).map(|entity| entity.common().handle).collect(); + ordered.sort_by_key(|handle| (overrides.get(handle).copied().unwrap_or(handle.value()), handle.value())); + let selected_set: std::collections::HashSet<_> = selected.iter().copied().collect(); + let reference_set: std::collections::HashSet<_> = references.iter().copied().collect(); + let moved: Vec<_> = ordered.iter().copied().filter(|handle| selected_set.contains(handle)).collect(); + if moved.is_empty() { return None; } + ordered.retain(|handle| !selected_set.contains(handle)); + let indices: Vec<_> = ordered.iter().enumerate().filter_map(|(index, handle)| reference_set.contains(handle).then_some(index)).collect(); + let insertion = if above { indices.into_iter().max()? + 1 } else { indices.into_iter().min()? }; + ordered.splice(insertion..insertion, moved); + Some(ordered.into_iter().enumerate().map(|(index, handle)| (handle, index as u64 + 1)).collect()) +} /// Sort-key assignments sending `group` to the back of the active space. /// /// Normal case: the floor is the lowest effective sort key among non-moved @@ -2062,11 +2119,112 @@ mod tests { assert!(hatch2_sort < ref_sort, "hatch 2 ({hatch2_sort}) must be under reference ({ref_sort})"); assert_ne!(hatch1_sort, hatch2_sort, "multi-select UNDER must not tie selected entities"); assert!( - hatch1_sort > hatch2_sort, + hatch1_sort < hatch2_sort, "selection order must be preserved within the moved group ({hatch1_sort}, {hatch2_sort})" ); } + #[test] + fn relative_group_uses_extremal_references_and_preserves_internal_order() { + let mut app = fresh_app(); + let i = app.active_tab; + let moved_first = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let reference_low = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let middle = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let reference_high = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let moved_last = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let block = app.tabs[i].scene.current_layout_block_handle_pub(); + let selected = [moved_first, moved_last]; + let references = [reference_low, reference_high]; + + let ordered = |above| { + let mut assignments = assign_relative_group_keys( + &app.tabs[i].scene.document, + block, + &selected, + &references, + above, + ) + .expect("references belong to the active block"); + assignments.sort_by_key(|(_, sort)| *sort); + assignments.into_iter().map(|(handle, _)| handle).collect::>() + }; + + assert_eq!( + ordered(true), + vec![reference_low, middle, reference_high, moved_first, moved_last] + ); + assert_eq!( + ordered(false), + vec![moved_first, moved_last, reference_low, middle, reference_high] + ); + } + + #[test] + fn relative_group_rejects_unusable_references_without_creating_a_table() { + let mut app = fresh_app(); + let i = app.active_tab; + let moved = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + app.tabs[i].scene.replace_selection(std::iter::once(moved).collect()); + + let _ = app.run_command_line("DRAWORDER ABOVE deadbeef"); + + assert!(!app.tabs[i].scene.document.objects.values().any(|object| { + matches!(object, ObjectType::SortEntitiesTable(_)) + })); + } + + #[test] + fn draw_order_table_reattaches_orphan_and_advances_object_handles() { + use acadrust::objects::SortEntitiesTable; + + let mut app = fresh_app(); + let i = app.active_tab; + let block = app.tabs[i].scene.current_layout_block_handle_pub(); + let entity = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let orphan_handle = acadrust::Handle::new(0x10_0000); + let mut orphan = SortEntitiesTable::for_block(block); + orphan.handle = orphan_handle; + orphan.add_entry(entity, acadrust::Handle::new(7)); + app.tabs[i] + .scene + .document + .objects + .insert(orphan_handle, ObjectType::SortEntitiesTable(orphan)); + + let resolved = ensure_draw_order_table(&mut app.tabs[i].scene.document, block); + assert_eq!(resolved, orphan_handle); + let dictionary_handle = app.tabs[i] + .scene + .document + .extension_dictionary_handle(block) + .expect("block extension dictionary"); + let ObjectType::Dictionary(dictionary) = app.tabs[i] + .scene + .document + .objects + .get(&dictionary_handle) + .expect("dictionary object") + else { + panic!("extension object must be a dictionary"); + }; + assert_eq!(dictionary.get(SortEntitiesTable::DICTIONARY_KEY), Some(orphan_handle)); + let ObjectType::SortEntitiesTable(table) = app.tabs[i] + .scene + .document + .objects + .get(&orphan_handle) + .expect("sort table") + else { + panic!("orphan must remain a sort table"); + }; + assert_eq!(table.owner_handle, dictionary_handle); + assert!(table.entries().any(|entry| { + entry.entity_handle == entity && entry.sort_handle.value() == 7 + })); + assert!(app.tabs[i].scene.document.allocate_handle().value() > orphan_handle.value()); + } + #[test] fn draworder_front_moves_hatch_in_front_of_all() { let mut app = fresh_app(); @@ -2175,18 +2333,18 @@ mod tests { _ => panic!("Expected Relaunch for shortcut B"), } - // 3. Shortcut 'A' -> Advances to PickReference (above) + // 3. Shortcut 'A' -> Advances to reference gathering (above) let mut cmd = DrawOrderCommand::new(vec![h_hatch]); assert!(!cmd.needs_entity_pick()); let res = cmd.on_text_input("A"); assert!(matches!(res, Some(crate::command::CmdResult::NeedPoint))); - assert!(cmd.needs_entity_pick(), "Needs entity pick after choosing 'A'"); + assert!(cmd.is_selection_gathering()); - // 4. Shortcut 'U' -> Advances to PickReference (under) + // 4. Shortcut 'U' -> Advances to reference gathering (under) let mut cmd = DrawOrderCommand::new(vec![h_hatch]); let res = cmd.on_text_input("U"); assert!(matches!(res, Some(crate::command::CmdResult::NeedPoint))); - assert!(cmd.needs_entity_pick(), "Needs entity pick after choosing 'U'"); + assert!(cmd.is_selection_gathering()); } #[test] @@ -2198,14 +2356,15 @@ mod tests { let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); - // Above with viewport pick + // Above with a gathered viewport selection let mut cmd = DrawOrderCommand::new(vec![h_hatch]); let _ = cmd.on_text_input("A"); - assert!(cmd.needs_entity_pick()); - let pick_res = cmd.on_entity_pick(h_ref, glam::DVec3::ZERO); + assert!(cmd.is_selection_gathering()); + let _ = cmd.on_selection_complete(vec![h_hatch, h_ref]); + let pick_res = cmd.on_enter(); match pick_res { crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => { - assert_eq!(relaunch_cmd, format!("DRAWORDER A {:x}", h_ref.value())); + assert_eq!(relaunch_cmd, format!("DRAWORDER ABOVE {:x}", h_ref.value())); assert_eq!(handles, vec![h_hatch]); app.tabs[i].scene.replace_selection(handles.into_iter().collect()); let _ = app.run_command_line(&relaunch_cmd); @@ -2218,14 +2377,15 @@ mod tests { let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); assert!(hatch_sort > ref_sort, "Hatch must be above reference after viewport pick"); - // Under with viewport pick + // Under with a gathered viewport selection let mut cmd = DrawOrderCommand::new(vec![h_hatch]); let _ = cmd.on_text_input("U"); - assert!(cmd.needs_entity_pick()); - let pick_res = cmd.on_entity_pick(h_ref, glam::DVec3::ZERO); + assert!(cmd.is_selection_gathering()); + let _ = cmd.on_selection_complete(vec![h_hatch, h_ref]); + let pick_res = cmd.on_enter(); match pick_res { crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => { - assert_eq!(relaunch_cmd, format!("DRAWORDER U {:x}", h_ref.value())); + assert_eq!(relaunch_cmd, format!("DRAWORDER UNDER {:x}", h_ref.value())); assert_eq!(handles, vec![h_hatch]); app.tabs[i].scene.replace_selection(handles.into_iter().collect()); let _ = app.run_command_line(&relaunch_cmd); @@ -2252,9 +2412,10 @@ mod tests { let _ = cmd.on_text_input("Above"); let hex_input = format!("0x{:x}", h_ref.value()); let typed_res = cmd.on_text_input(&hex_input); - match typed_res { - Some(crate::command::CmdResult::Relaunch(relaunch_cmd, handles)) => { - assert_eq!(relaunch_cmd, format!("DRAWORDER A {:x}", h_ref.value())); + assert!(matches!(typed_res, Some(crate::command::CmdResult::NeedPoint))); + match cmd.on_enter() { + crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => { + assert_eq!(relaunch_cmd, format!("DRAWORDER ABOVE {:x}", h_ref.value())); assert_eq!(handles, vec![h_hatch]); app.tabs[i].scene.replace_selection(handles.into_iter().collect()); let _ = app.run_command_line(&relaunch_cmd); @@ -2306,4 +2467,35 @@ mod tests { let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); assert!(hatch_sort > line_sort); } + + #[test] + fn draworder_mode_aliases_route_directly() { + let mut app = fresh_app(); + let i = app.active_tab; + let line = app.tabs[i] + .scene + .add_entity_clone(EntityType::Line(Default::default())); + let hatch = app.tabs[i] + .scene + .add_entity_clone(EntityType::Hatch(Default::default())); + + app.tabs[i] + .scene + .replace_selection(std::iter::once(hatch).collect()); + let _ = app.run_command_line("DRAWORDER_FRONT"); + let entries = effective_sort_map(&app); + assert!( + entries.get(&hatch.value()).copied().unwrap_or(hatch.value()) + > entries.get(&line.value()).copied().unwrap_or(line.value()) + ); + + app.tabs[i] + .scene + .replace_selection(std::iter::once(hatch).collect()); + let _ = app.run_command_line("DRAWORDER_ABOVE"); + assert!(app.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.name() == "DRAWORDER" && command.is_selection_gathering())); + } } diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs deleted file mode 100644 index 4c27a8fa1..000000000 --- a/src/app/doc_api.rs +++ /dev/null @@ -1,4501 +0,0 @@ -//! Native document API adapter over the existing scene, history and kernel paths. - -use acadrust::entities::Solid3D; -use acadrust::objects::{Dictionary, ObjectType}; -use acadrust::{EntityType, Handle}; -use ocs_doc_api::backend::{DocApiBackend, KernelBody}; -use ocs_doc_api::{ - Aabb, ApiError, ApiResult, Color, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, - LayerFlags, LayerInfo, LineWeight, ObjectId, PlacementSpec, -}; - -use crate::scene::annotative::root_named_dict_handle; -use crate::scene::convert::acis_export; -use crate::scene::model::solid_model; -use ocs_doc_api::convert; - -use super::plugin_host::HostSession; - -const XRECORD_DICT_NAME: &str = "OCS_XRECORD_DICT"; - -/// Return the stable named-object dictionary used to hold `XRecord` objects. -/// Created on first use under the root named-objects dictionary and reused -/// thereafter. -fn xrecord_dictionary_handle(doc: &mut acadrust::CadDocument) -> Handle { - let root_h = root_named_dict_handle(doc); - let existing = match doc.objects.get(&root_h) { - Some(ObjectType::Dictionary(root)) => root - .entries - .iter() - .find(|(name, _)| name.eq_ignore_ascii_case(XRECORD_DICT_NAME)) - .map(|(_, handle)| *handle), - _ => None, - }; - match existing.filter(|handle| matches!(doc.objects.get(handle), Some(ObjectType::Dictionary(_)))) { - Some(handle) => handle, - None => { - let handle = doc.allocate_handle(); - let mut dict = Dictionary::new(); - dict.handle = handle; - dict.owner = root_h; - doc.objects.insert(handle, ObjectType::Dictionary(dict)); - if let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&root_h) { - root.entries.retain(|(name, _)| !name.eq_ignore_ascii_case(XRECORD_DICT_NAME)); - root.add_entry(XRECORD_DICT_NAME, handle); - } - handle - } - } -} - -fn xrecord_owner_dictionary( - doc: &acadrust::CadDocument, - record_handle: Handle, - owner: Handle, -) -> Option { - let owns_record = |handle: Handle| { - matches!(doc.objects.get(&handle), Some(ObjectType::Dictionary(dict)) if - dict.entries.iter().any(|(_, child)| *child == record_handle)) - }; - if owns_record(owner) { - return Some(owner); - } - doc.objects.iter().find_map(|(handle, object)| match object { - ObjectType::Dictionary(dict) - if dict.entries.iter().any(|(_, child)| *child == record_handle) => - { - Some(*handle) - } - _ => None, - }) -} - -/// Entry point called by the `HostApi::doc_api_dispatch` override (below) and by -// the in-process path. Deserializes the envelope, runs the crate executor, -// serializes the `Receipt` (or `ApiError`) back to bytes. -pub fn execute_doc_api( - host: &mut HostSession<'_>, - tab_id: u64, - bytes: &[u8], -) -> Result, String> { - use bincode::Options; - use ocs_doc_api::{DocApiEnvelope, EnvelopeBody}; - // Authorization: the request must name the SAME tab the HostSession is bound to. - // The HostSession is built per-tab by the dispatch pump; a plugin naming a - // different tab_id would otherwise reach a tab it isn't bound to (confused deputy). - if tab_id != host.tab_id() { - return Err(format!( - "DocApi tab mismatch: request names tab {tab_id} but the bound tab is {}", - host.tab_id() - )); - } - host.scene_mut().doc_api_cold_tess_used = 0; - const FRAME_LIMIT: u64 = 64 * 1024 * 1024; - if bytes.len() as u64 > FRAME_LIMIT { - return Err("DocApi frame exceeds 64 MiB".into()); - } - let envelope: DocApiEnvelope = bincode::DefaultOptions::new() - .with_fixint_encoding() - .with_limit(FRAME_LIMIT) - .reject_trailing_bytes() - .deserialize(bytes) - .map_err(|e| format!("DocApiEnvelope deserialize: {e}"))?; - let result: ApiResult = - envelope - .validate_version() - .and_then(|_| match envelope.body { - EnvelopeBody::Op(op) => ocs_doc_api::executor::apply_op(host, op), - EnvelopeBody::Queries(qs) => ocs_doc_api::executor::apply_queries(host, qs), - }); - bincode::serialize(&result).map_err(|e| format!("Receipt serialize: {e}")) -} - -// ObjectId <-> acadrust::Handle converters delegate to the crate's helpers -// (single source of truth; no local duplicates). -fn obj_to_handle(id: ObjectId) -> Handle { - id.to_handle() -} -fn handle_to_obj(h: Handle) -> ObjectId { - ObjectId::from_handle(h) -} - -fn v3_to_array(v: acadrust::types::Vector3) -> [f64; 3] { - [v.x, v.y, v.z] -} - -impl DocApiBackend for HostSession<'_> { - fn resolve_body(&mut self, id: ObjectId) -> ApiResult { - let handle = obj_to_handle(id); - // Lift-on-miss: populate the solid_models cache from AcisData if absent. - self.scene_mut().restore_solid_models(&[handle]); - self.scene() - .solid_models - .get(&handle) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - - fn with_body( - &mut self, - id: ObjectId, - f: &mut dyn FnMut(&KernelBody) -> ApiResult, - ) -> ApiResult { - let handle = obj_to_handle(id); - // Lift-on-miss, then borrow the cache entry in place — O(1), no deep clone. - self.scene_mut().restore_solid_models(&[handle]); - let body = self - .scene() - .solid_models - .get(&handle) - .ok_or(ApiError::UnknownId(id))?; - f(body) - } - - fn store_solid(&mut self, body: &KernelBody) -> ApiResult { - let prepared = self.prepare_doc_solid(body.clone(), None)?; - Ok(self.apply_doc_entity(prepared, None)) - } - - fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> { - self.can_modify(id)?; - let prepared = self.prepare_doc_solid(body.clone(), Some(id))?; - self.apply_doc_entity(prepared, Some(id)); - Ok(()) - } - - fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { - // Validate per-spec layers up front so the op is atomic. - for spec in specs { - if let Some(name) = spec.layer() { - self.lookup_layer(name).map_err(|e| ApiError::validation( - "CreateMany", - e.to_string(), - ))?; - } - } - let mut prepared = Vec::with_capacity(specs.len()); - for spec in specs { - prepared.push(match spec { - ocs_doc_api::EntitySpec::Solid(spec) => { - self.prepare_doc_solid(ocs_doc_api::geom::make_solid(spec)?, None)? - } - ocs_doc_api::EntitySpec::Curve(spec) => { - let mut entity = convert::curve_spec_to_entity(spec)?; - if let Some(name) = spec.layer() { - entity.common_mut().layer = - acadrust::tables::normalize_name(name.trim()); - } - PreparedDocEntity { - entity, - solid: None, - } - } - }); - } - DocApiBackend::push_undo(self, "CreateMany"); - let ids = prepared - .into_iter() - .map(|entity| self.apply_doc_entity(entity, None)) - .collect(); - self.finalize_op(); - Ok(ids) - } - - fn transform_many(&mut self, ids: &[ObjectId], placement: &PlacementSpec) -> ApiResult<()> { - let prepared = ids - .iter() - .map(|&id| self.prepare_doc_transform(id, placement)) - .collect::>>()?; - DocApiBackend::push_undo(self, "TransformMany"); - for (&id, entity) in ids.iter().zip(prepared) { - self.apply_doc_entity(entity, Some(id)); - } - self.finalize_op(); - Ok(()) - } - - fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - let layer_name = spec.layer(); - if let Some(name) = layer_name { - self.lookup_layer(name).map_err(|e| ApiError::validation( - "CreateCurve", - e.to_string(), - ))?; - } - let mut entity = convert::curve_spec_to_entity(spec)?; - if let Some(name) = layer_name { - entity.common_mut().layer = acadrust::tables::normalize_name(name.trim()); - } - let handle = self.scene_mut().add_entity(entity); - Ok(handle_to_obj(handle)) - } - - fn lookup_layer(&self, name: &str) -> ApiResult { - let norm = acadrust::tables::normalize_name(name.trim()); - if norm.is_empty() { - return Err(ApiError::validation("LookupLayer", "empty layer name")); - } - self.document() - .layers - .get(&norm) - .map(|l| layer_info_from_acadrust(l)) - .ok_or_else(|| { - ApiError::validation( - "LookupLayer", - format!("layer '{name}' does not exist"), - ) - }) - } - - fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { - // Validate the referenced block exists before committing. - if self - .document() - .block_records - .get(&spec.block_name) - .is_none() - { - return Err(ApiError::validation( - "CreateInsert", - format!("unknown block {:?}", spec.block_name), - )); - } - let mut ins = acadrust::entities::Insert::new( - spec.block_name.clone(), - acadrust::types::Vector3::new( - spec.insert_point[0], - spec.insert_point[1], - spec.insert_point[2], - ), - ); - ins.rotation = spec.rotation; - ins.set_x_scale(spec.scale); - ins.set_y_scale(spec.scale); - ins.set_z_scale(spec.scale); - let handle = self.scene_mut().add_entity(EntityType::Insert(ins)); - Ok(handle_to_obj(handle)) - } - - fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { - use acadrust::entities::Viewport; - use acadrust::types::Vector3; - let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); - let mut vp = Viewport::new(); - vp.center = v3(spec.center); - vp.width = spec.width; - vp.height = spec.height; - vp.view_target = v3(spec.view_target); - vp.view_height = spec.view_height; - let handle = self.scene_mut().add_entity(EntityType::Viewport(vp)); - Ok(handle_to_obj(handle)) - } - - fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { - use acadrust::entities::Text; - use acadrust::types::Vector3; - let mut t = Text::new(); - t.value = spec.value.clone(); - t.insertion_point = Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ); - t.height = spec.height; - t.rotation = spec.rotation; - let handle = self.scene_mut().add_entity(EntityType::Text(t)); - Ok(handle_to_obj(handle)) - } - - fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult { - use acadrust::entities::MText; - use acadrust::types::Vector3; - let mut t = MText::with_value( - spec.value.clone(), - Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ), - ); - t.height = spec.height; - let handle = self.scene_mut().add_entity(EntityType::MText(t)); - Ok(handle_to_obj(handle)) - } - - fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .cloned() - .ok_or(ApiError::UnknownId(id))?; - let new_entity = match entity { - EntityType::Text(mut t) => { - t.value = value.to_string(); - EntityType::Text(t) - } - EntityType::MText(mut t) => { - t.value = value.to_string(); - EntityType::MText(t) - } - _ => { - return Err(ApiError::Unsupported( - "SetTextContent is only for Text/MText".into(), - )) - } - }; - if !self.scene_mut().update_entity(new_entity) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - Ok(()) - } - - fn text_content(&self, id: ObjectId) -> ApiResult { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - match entity { - EntityType::Text(t) => Ok(t.value.clone()), - EntityType::MText(t) => Ok(t.value.clone()), - _ => Err(ApiError::Unsupported( - "GetTextContent is only for Text/MText".into(), - )), - } - } - - fn can_modify(&self, id: ObjectId) -> ApiResult<()> { - let handle = obj_to_handle(id); - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - if self.scene().is_layer_locked(handle) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - Ok(()) - } - - fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult { - use acadrust::entities::{BoundaryEdge, BoundaryPath, Hatch, LineEdge}; - use acadrust::types::Vector2; - let mut hatch = if spec.solid { - Hatch::solid() - } else { - Hatch::default() - }; - // Build a BoundaryPath of Line edges around the closed polyline. - let n = spec.boundary.len(); - let mut path = BoundaryPath::new(); - for i in 0..n { - let start = spec.boundary[i]; - let end = spec.boundary[(i + 1) % n]; - path.edges.push(BoundaryEdge::Line(LineEdge { - start: Vector2::new(start[0], start[1]), - end: Vector2::new(end[0], end[1]), - })); - } - hatch.paths.push(path); - let handle = self.scene_mut().add_entity(EntityType::Hatch(hatch)); - Ok(handle_to_obj(handle)) - } - - fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Hatch(h) = entity else { - return Err(ApiError::Unsupported( - "GetHatchBoundary is only for Hatch".into(), - )); - }; - // Reconstruct each boundary loop's vertices from its Line edges. - let mut loops = Vec::with_capacity(h.paths.len()); - for path in &h.paths { - let mut pts = Vec::with_capacity(path.edges.len()); - for edge in &path.edges { - match edge { - acadrust::entities::BoundaryEdge::Line(le) => { - pts.push([le.start.x, le.start.y]) - } - // Arc/ellipse boundary edges are a later refinement (no vertex list). - _ => { - return Err(ApiError::Unsupported( - "hatch boundary contains non-line edges".into(), - )) - } - } - } - loops.push(pts); - } - Ok(loops) - } - - fn add_dimension_linear( - &mut self, - spec: &ocs_doc_api::ops::DimensionSpec, - ) -> ApiResult { - use acadrust::entities::{Dimension, DimensionLinear}; - use acadrust::types::Vector3; - let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); - let mut dim = DimensionLinear::new(v3(spec.first_point), v3(spec.second_point)); - dim.definition_point = v3(spec.definition_point); - let handle = self - .scene_mut() - .add_entity(EntityType::Dimension(Dimension::Linear(dim))); - Ok(handle_to_obj(handle)) - } - - fn add_dimension_radius( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - use acadrust::entities::{Dimension, DimensionRadius}; - use acadrust::types::Vector3; - let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); - let dim = DimensionRadius::new(v3(spec.center), v3(spec.point)); - let handle = self - .scene_mut() - .add_entity(EntityType::Dimension(Dimension::Radius(dim))); - Ok(handle_to_obj(handle)) - } - - fn add_dimension_diameter( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - use acadrust::entities::{Dimension, DimensionDiameter}; - use acadrust::types::Vector3; - let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); - let dim = DimensionDiameter::new(v3(spec.center), v3(spec.point)); - let handle = self - .scene_mut() - .add_entity(EntityType::Dimension(Dimension::Diameter(dim))); - Ok(handle_to_obj(handle)) - } - - fn add_dimension_angular( - &mut self, - spec: &ocs_doc_api::ops::DimensionAngularSpec, - ) -> ApiResult { - use acadrust::entities::{Dimension, DimensionAngular3Pt}; - use acadrust::types::Vector3; - let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); - let mut dim = - DimensionAngular3Pt::new(v3(spec.vertex), v3(spec.first_point), v3(spec.second_point)); - dim.definition_point = v3(spec.arc_location); - let handle = self - .scene_mut() - .add_entity(EntityType::Dimension(Dimension::Angular3Pt(dim))); - Ok(handle_to_obj(handle)) - } - - fn dimension_measurement(&self, id: ObjectId) -> ApiResult { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Dimension(d) = entity else { - return Err(ApiError::Unsupported( - "GetDimensionMeasurement is only for Dimension".into(), - )); - }; - // Distance for linear/radius/diameter; degrees for angular. - Ok(match d { - acadrust::entities::Dimension::Linear(x) => x.measurement(), - acadrust::entities::Dimension::Radius(x) => x.measurement(), - acadrust::entities::Dimension::Diameter(x) => x.measurement(), - acadrust::entities::Dimension::Angular3Pt(x) => x.measurement_degrees(), - acadrust::entities::Dimension::Angular2Ln(x) => x.measurement_degrees(), - _ => { - return Err(ApiError::Unsupported( - "dimension measurement for this sub-type".into(), - )) - } - }) - } - - fn add_attribute_definition( - &mut self, - spec: &ocs_doc_api::ops::AttributeDefinitionSpec, - ) -> ApiResult { - use acadrust::entities::AttributeDefinition; - use acadrust::types::Vector3; - let att = AttributeDefinition { - tag: spec.tag.clone(), - prompt: spec.prompt.clone(), - default_value: spec.default_value.clone(), - insertion_point: Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ), - height: spec.height, - rotation: spec.rotation, - ..Default::default() - }; - let handle = self - .scene_mut() - .add_entity(EntityType::AttributeDefinition(att)); - Ok(handle_to_obj(handle)) - } - - fn add_dimension_angular2ln( - &mut self, - spec: &ocs_doc_api::ops::DimensionAngularSpec, - ) -> ApiResult { - use acadrust::entities::{Dimension, DimensionAngular2Ln}; - use acadrust::types::Vector3; - let v3 = |p: [f64; 3]| Vector3::new(p[0], p[1], p[2]); - let mut dim = - DimensionAngular2Ln::new(v3(spec.vertex), v3(spec.first_point), v3(spec.second_point)); - dim.dimension_arc = v3(spec.arc_location); - let handle = self - .scene_mut() - .add_entity(EntityType::Dimension(Dimension::Angular2Ln(dim))); - Ok(handle_to_obj(handle)) - } - - fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { - use acadrust::entities::Table; - use acadrust::types::Vector3; - let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); - let mut table = Table::new( - Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ), - spec.data.len(), - cols, - ); - // Fill cells from the grid (each cell = text). - for (r, row) in spec.data.iter().enumerate() { - for (c, text) in row.iter().enumerate() { - table.set_cell_text(r, c, text); - } - } - let handle = self.scene_mut().add_entity(EntityType::Table(table)); - Ok(handle_to_obj(handle)) - } - - fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .cloned() - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Insert(mut ins) = entity else { - return Err(ApiError::Unsupported( - "SetAttribute is only for Insert entities".into(), - )); - }; - if let Some(attr) = ins.attributes.iter_mut().find(|a| a.tag == tag) { - attr.value = value.to_string(); - } else { - let attr = acadrust::entities::AttributeEntity { - tag: tag.to_string(), - value: value.to_string(), - insertion_point: ins.insert_point, - ..Default::default() - }; - ins.attributes.push(attr); - } - if !self.scene_mut().update_entity(EntityType::Insert(ins)) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - Ok(()) - } - - fn attributes(&self, id: ObjectId) -> ApiResult> { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Insert(ins) = entity else { - return Err(ApiError::Unsupported( - "GetAttributes is only for Insert entities".into(), - )); - }; - Ok(ins - .attributes - .iter() - .map(|a| (a.tag.clone(), a.value.clone())) - .collect()) - } - - fn block_entities(&self, block_name: &str) -> ApiResult> { - let br = self - .document() - .block_records - .get(block_name) - .ok_or_else(|| { - ApiError::validation("GetBlockEntities", format!("unknown block {block_name:?}")) - })?; - // Read-only traversal of the block definition's entities (handle lookups). - let mut out = Vec::with_capacity(br.entity_handles.len()); - for h in &br.entity_handles { - let id = handle_to_obj(*h); - let (kind, bounds) = match self.document().get_entity(*h) { - Some(e) => ( - convert::entity_kind_name(e).to_string(), - convert::entity_bounds(Some(e), id).ok(), - ), - None => ("Missing".to_string(), None), - }; - let layer = match self.document().get_entity(*h) { - Some(e) => e.common().layer.clone(), - None => String::new(), - }; - out.push(EntityView { - id, - kind, - layer, - bounds, - }); - } - Ok(out) - } - - fn set_viewport_view( - &mut self, - id: ObjectId, - view_target: [f64; 3], - view_height: f64, - ) -> ApiResult<()> { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .cloned() - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Viewport(mut vp) = entity else { - return Err(ApiError::Unsupported( - "SetViewportView is only for Viewport".into(), - )); - }; - vp.view_target = - acadrust::types::Vector3::new(view_target[0], view_target[1], view_target[2]); - vp.view_height = view_height; - if !self.scene_mut().update_entity(EntityType::Viewport(vp)) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - Ok(()) - } - - fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Viewport(vp) = entity else { - return Err(ApiError::Unsupported( - "GetViewportView is only for Viewport".into(), - )); - }; - Ok(( - [vp.view_target.x, vp.view_target.y, vp.view_target.z], - vp.view_height, - )) - } - - fn add_raster_image( - &mut self, - spec: &ocs_doc_api::ops::RasterImageSpec, - ) -> ApiResult { - use acadrust::entities::RasterImage; - use acadrust::types::{Vector2, Vector3}; - // Validate the plugin-supplied path before embedding it in the document: - // non-empty, a known image extension, and not a UNC/network path or - // parent-traversal (which could point the host at unintended resources). - validate_image_path(&spec.file_path)?; - let img = RasterImage { - file_path: spec.file_path.clone(), - insertion_point: Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ), - u_vector: Vector3::new(spec.u_vector[0], spec.u_vector[1], spec.u_vector[2]), - v_vector: Vector3::new(spec.v_vector[0], spec.v_vector[1], spec.v_vector[2]), - size: Vector2::new(spec.size[0], spec.size[1]), - ..Default::default() - }; - // add_entity auto-registers the ImageDefinition (scene/entity.rs). - let handle = self.scene_mut().add_entity(EntityType::RasterImage(img)); - Ok(handle_to_obj(handle)) - } - - fn loft( - &mut self, - sections: &[(cadkernel::space::Plane, Vec)], - ) -> ApiResult { - let body = cadkernel::brep::loft(sections) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "loft failed"))?; - self.store_solid(&body) - } - - fn set_xdata( - &mut self, - id: ObjectId, - application_name: &str, - record: Option<&ocs_doc_api::XDataRecord>, - ) -> ApiResult<()> { - self.can_modify(id)?; - let handle = obj_to_handle(id); - let values = record.map(|r| { - r.values - .iter() - .map(|v| xdata_value_to_acadrust(v, self.document())) - .collect::>() - }); - crate::scene::view::dispatch::set_entity_xdata( - self.document_mut(), - handle, - application_name, - values, - ); - Ok(()) - } - - fn xdata(&self, id: ObjectId, application_name: &str) -> ApiResult> { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .ok_or(ApiError::UnknownId(id))?; - let Some(rec) = entity.common().extended_data.get_record(application_name) else { - return Ok(None); - }; - Ok(Some(ocs_doc_api::XDataRecord { - application_name: rec.application_name.clone(), - values: rec - .values - .iter() - .map(xdata_value_from_acadrust) - .collect(), - })) - } - - fn object_exists(&self, id: ObjectId) -> bool { - let handle = obj_to_handle(id); - self.document().objects.contains_key(&handle) - } - - fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult { - use acadrust::objects::{ObjectType, XRecord}; - let dict_h = xrecord_dictionary_handle(self.document_mut()); - if matches!(self.document().objects.get(&dict_h), Some(ObjectType::Dictionary(dict)) if - dict.entries.iter().any(|(name, _)| name.eq_ignore_ascii_case(&spec.name))) - { - return Err(ApiError::validation( - "CreateXRecord", - format!("XRecord '{}' already exists", spec.name), - )); - } - let mut record = XRecord::named(spec.name.clone()); - record.owner = dict_h; - record.cloning_flags = cloning_flags_to_acadrust(spec.cloning_flags); - record.entries = spec - .entries - .iter() - .map(xrecord_entry_to_acadrust) - .collect(); - record.synchronize_object_references(); - let record_handle = { - let doc = self.document_mut(); - let handle = doc.allocate_handle(); - record.handle = handle; - doc.objects - .insert(handle, ObjectType::XRecord(record)); - handle - }; - // Attach the new XRecord to the stable named-object dictionary so it is - // reachable as a standalone named object. - if let Some(ObjectType::Dictionary(dict)) = self.document_mut().objects.get_mut(&dict_h) { - dict.add_entry(spec.name.clone(), record_handle); - } - Ok(handle_to_obj(record_handle)) - } - - fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "SetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - let handle = obj_to_handle(id); - let Some(ObjectType::XRecord(record)) = self.document().objects.get(&handle) else { - return Err(ApiError::Unsupported(format!("ObjectId {id:?} is not an XRecord"))); - }; - let mut updated = record.clone(); - let dict_h = xrecord_owner_dictionary(self.document(), handle, record.owner) - .unwrap_or_else(|| xrecord_dictionary_handle(self.document_mut())); - if matches!(self.document().objects.get(&dict_h), Some(ObjectType::Dictionary(dict)) if - dict.entries.iter().any(|(name, child)| { - *child != handle && name.eq_ignore_ascii_case(&spec.name) - })) - { - return Err(ApiError::validation( - "SetXRecord", - format!("XRecord '{}' already exists", spec.name), - )); - } - updated.name = spec.name.clone(); - updated.owner = dict_h; - updated.cloning_flags = cloning_flags_to_acadrust(spec.cloning_flags); - updated.entries = spec - .entries - .iter() - .map(xrecord_entry_to_acadrust) - .collect(); - updated.synchronize_object_references(); - self.document_mut() - .objects - .insert(handle, acadrust::objects::ObjectType::XRecord(updated)); - if let Some(ObjectType::Dictionary(dict)) = self.document_mut().objects.get_mut(&dict_h) { - dict.entries.retain(|(_, child)| *child != handle); - dict.add_entry(spec.name.clone(), handle); - } - Ok(()) - } - - fn xrecord(&self, id: ObjectId) -> ApiResult> { - let handle = obj_to_handle(id); - let Some(ObjectType::XRecord(record)) = self.document().objects.get(&handle) else { - return Ok(None); - }; - Ok(Some(ocs_doc_api::XRecordSpec { - name: record.name.clone(), - cloning_flags: cloning_flags_from_acadrust(record.cloning_flags), - entries: record - .entries - .iter() - .map(xrecord_entry_from_acadrust) - .collect(), - })) - } - - fn create_layer(&mut self, info: &LayerInfo) -> ApiResult<()> { - let name = acadrust::tables::normalize_name(info.name.trim()); - if name.is_empty() { - return Err(ApiError::validation("CreateLayer", "empty layer name")); - } - if self.document().layers.contains(&name) { - return Err(ApiError::validation( - "CreateLayer", - format!("layer '{name}' already exists"), - )); - } - let mut layer = acadrust::tables::Layer::new(&name); - layer.handle = self.document_mut().allocate_handle(); - apply_layer_info(&mut layer, info); - self.document_mut() - .layers - .add(layer) - .map_err(|e| ApiError::validation("CreateLayer", e))?; - Ok(()) - } - - fn update_layer(&mut self, name: &str, info: &LayerInfo) -> ApiResult<()> { - let deps = { - let doc = self.document_mut(); - let name_upper = acadrust::tables::normalize_name(name.trim()); - let Some(existing) = doc.layers.get(&name_upper).cloned() else { - return Err(ApiError::validation( - "UpdateLayer", - format!("layer '{name}' does not exist"), - )); - }; - let new_name = acadrust::tables::normalize_name(info.name.trim()); - if new_name.is_empty() { - return Err(ApiError::validation("UpdateLayer", "empty layer name")); - } - // Rename if the key changed (preserves handle and table position). - if name_upper != new_name { - doc.layers - .rename(&name_upper, new_name.clone()) - .map_err(|e| ApiError::validation("UpdateLayer", e))?; - } - let Some(layer) = doc.layers.get_mut(&new_name) else { - return Err(ApiError::validation( - "UpdateLayer", - format!("layer '{name}' does not exist"), - )); - }; - apply_layer_info(layer, info); - [existing.name.clone(), layer.name.clone()] - }; - // By-layer appearance changed: rebuild derived geometry that resolved it. - self.scene_mut().invalidate_layer_dependencies(&deps); - Ok(()) - } - fn delete_layer(&mut self, name: &str) -> ApiResult<()> { - let name_upper = acadrust::tables::normalize_name(name.trim()); - if name_upper == "0" { - return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0")); - } - if !self.document().layers.contains(&name_upper) { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name}' does not exist"), - )); - } - if acadrust::tables::normalize_name(&self.document().header.current_layer_name) == name_upper - { - return Err(ApiError::validation( - "DeleteLayer", - "cannot delete the current layer", - )); - } - if self - .document() - .entities() - .any(|e| acadrust::tables::normalize_name(&e.common().layer) == name_upper) - { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name}' is still in use"), - )); - } - // Remove by the normalized name so a differently-cased request behaves - // consistently with the rest of the layer table API. - self.document_mut().layers.remove(&name_upper); - Ok(()) - } - - - fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> { - self.can_modify(id)?; - let handle = obj_to_handle(id); - let layer_upper = acadrust::tables::normalize_name(layer.trim()); - let target = self.document().layers.get(&layer_upper).cloned(); - let Some(target) = target else { - return Err(ApiError::validation( - "SetEntityLayer", - format!("layer '{layer}' does not exist"), - )); - }; - if target.is_locked() { - return Err(ApiError::Unsupported(format!( - "entity {id:?} cannot be moved to locked layer '{layer}'" - ))); - } - let mut entity = self - .document() - .get_entity(handle) - .cloned() - .ok_or(ApiError::UnknownId(id))?; - entity.common_mut().layer = target.name.clone(); - if !self.scene_mut().update_entity(entity) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - Ok(()) - } - - fn layers(&self) -> ApiResult> { - let mut v: Vec<_> = self - .document() - .layers - .iter() - .map(layer_info_from_acadrust) - .collect(); - v.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(v) - } - - fn entity_layer(&self, id: ObjectId) -> ApiResult { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .ok_or(ApiError::UnknownId(id))?; - Ok(entity.common().layer.clone()) - } - - fn enumerate_entities( - &self, - kind: Option<&str>, - layer: Option<&str>, - include_bounds: bool, - ) -> ApiResult> { - let doc = self.document(); - let mut out = Vec::new(); - for entity in doc.entities() { - let handle = entity.common().handle; - let id = handle_to_obj(handle); - let entity_kind = convert::entity_kind_name(entity); - if let Some(k) = kind { - if !entity_kind.eq_ignore_ascii_case(k) { - continue; - } - } - if let Some(l) = layer { - if acadrust::tables::normalize_name(&entity.common().layer) - != acadrust::tables::normalize_name(l.trim()) - { - continue; - } - } - out.push(EntityView { - id, - kind: entity_kind.to_string(), - layer: entity.common().layer.clone(), - bounds: if include_bounds { - convert::entity_bounds(Some(entity), id).ok() - } else { - None - }, - }); - } - Ok(out) - } - - fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Point(pt) = entity else { - return Err(ApiError::Unsupported( - "GetPointPosition is only for Point entities".into(), - )); - }; - Ok(v3_to_array(pt.location)) - } - - fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Line(line) = entity else { - return Err(ApiError::Unsupported( - "GetLineGeometry is only for Line entities".into(), - )); - }; - Ok((v3_to_array(line.start), v3_to_array(line.end))) - } - - fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> { - let handle = obj_to_handle(id); - let Some(entity) = self.document().get_entity(handle).cloned() else { - return Err(ApiError::UnknownId(id)); - }; - let EntityType::LwPolyline(mut pl) = entity else { - return Err(ApiError::Unsupported( - "AddVertex is only implemented for polylines".into(), - )); - }; - if point[2] != pl.elevation || pl.vertices.len() >= ocs_doc_api::ops::BULK_ITEM_CAP { - return Err(ApiError::validation( - "AddVertex", - "vertex must share the polyline elevation and fit the item cap", - )); - } - if at > pl.vertices.len() { - return Err(ApiError::validation( - "AddVertex", - format!("index {at} out of range ({} vertices)", pl.vertices.len()), - )); - } - pl.vertices.insert( - at, - acadrust::entities::LwVertex::from_coords(point[0], point[1]), - ); - if !self.scene_mut().update_entity(EntityType::LwPolyline(pl)) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - Ok(()) - } - - fn remove_entity(&mut self, id: ObjectId) -> ApiResult { - let handle = obj_to_handle(id); - if !self.entity_exists(id) { - return Ok(false); - } - // Locked layers refuse deletion; surface that as an error so the executor - // does not treat a no-op as success. - if self.scene().is_layer_locked(handle) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - // erase_entities clears the entity + solid_models/meshes/hatches and - // records the undo delta; the single publish happens at finalize_op. - self.scene_mut().erase_entities(&[handle]); - Ok(self.document().get_entity(handle).is_none()) - } - - fn ensure_transformable(&mut self, id: ObjectId) -> ApiResult<()> { - // Pre-validating everything the apply loop can actually fail on is what makes - // TransformMany all-or-nothing (no mid-loop failure after earlier mutations). - // This must check more than the entity TYPE: layer-lock (update_entity returns - // false) and, for solids, that the body resolves. - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .ok_or(ApiError::UnknownId(id))?; - let ok = matches!( - entity, - EntityType::Solid3D(_) - | EntityType::Line(_) - | EntityType::Circle(_) - | EntityType::Arc(_) - | EntityType::Ellipse(_) - | EntityType::Spline(_) - | EntityType::Ray(_) - | EntityType::XLine(_) - | EntityType::Insert(_) - | EntityType::Viewport(_) - | EntityType::Text(_) - | EntityType::MText(_) - | EntityType::Point(_) - | EntityType::LwPolyline(_) - ); - if !ok { - return Err(ApiError::Unsupported( - "transform is not supported for this entity family".into(), - )); - } - // Locked layer -> update_entity would return false mid-loop. - if self.scene().is_layer_locked(handle) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - // Solid: the body must resolve (kernel transform/display-prep can still fail - // at apply time, but resolution is the checkable precondition). - if matches!(entity, EntityType::Solid3D(_)) { - self.resolve_body(id)?; - } - Ok(()) - } - - fn can_remove(&self, id: ObjectId) -> ApiResult<()> { - let handle = obj_to_handle(id); - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - if self.scene().is_layer_locked(handle) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - Ok(()) - } - - fn get_entity(&mut self, id: ObjectId) -> ApiResult { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .ok_or(ApiError::UnknownId(id))?; - Ok(EntityView { - id, - kind: convert::entity_kind_name(entity).to_string(), - layer: entity.common().layer.clone(), - bounds: self.bounds(id).ok(), - }) - } - - fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { - let prepared = self.prepare_doc_transform(id, placement)?; - self.apply_doc_entity(prepared, Some(id)); - Ok(()) - } - - fn profile_curves(&self, id: ObjectId) -> ApiResult> { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - crate::scene::model::sweep_model::extrusion_profile_of(entity) - .map(|(profile, _)| profile.pieces) - .ok_or_else(|| ApiError::Unsupported("entity is not a planar profile".into())) - } - - fn profile_plane(&self, id: ObjectId) -> ApiResult { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - crate::scene::model::sweep_model::extrusion_profile_of(entity) - .map(|(profile, _)| profile.plane) - .ok_or_else(|| ApiError::Unsupported("entity is not a planar profile".into())) - } - - fn bounds(&mut self, id: ObjectId) -> ApiResult { - let handle = obj_to_handle(id); - // Lift-on-miss for solids (consistent with volume/centroid); borrow the cache - // read-only via with_body (O(1), no B-rep deep clone). - if matches!( - self.document().get_entity(handle), - Some(EntityType::Solid3D(_)) - ) { - let mut f = |body: &KernelBody| -> ApiResult { - let bb = cadkernel::brep::body_bounds(body) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?; - Ok(Aabb { - min: bb.min, - max: bb.max, - }) - }; - return self.with_body(id, &mut f); - } - convert::entity_bounds(self.document().get_entity(handle), id) - } - - fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { - Ok(self.mass_properties(id)?.1) - } - - fn volume(&mut self, id: ObjectId) -> ApiResult { - Ok(self.mass_properties(id)?.0) - } - - fn entity_exists(&self, id: ObjectId) -> bool { - self.document().get_entity(obj_to_handle(id)).is_some() - } - - fn revision(&self) -> GeometryRevision { - GeometryRevision(self.scene().geometry_epoch) - } - - fn push_undo(&mut self, label: &str) { - // Begin undo capture for an entity-only delta (delta_safe = true: solids - // and curves are entity-store changes). - self.begin_doc_api_undo(label); - } - - fn cancel_op(&mut self) { - self.cancel_doc_api_undo(); - } - - fn finalize_op(&mut self) { - // Close the delta entry + bump geometry + republish the document view. - self.commit_doc_api_undo(); - } -} - -// ── helpers ────────────────────────────────────────────────────────────────── - -type SolidDisplay = ( - crate::scene::model::mesh_model::MeshLodSet, - Vec, - [f64; 3], -); - -struct PreparedDocEntity { - entity: EntityType, - solid: Option<(KernelBody, SolidDisplay)>, -} - -impl HostSession<'_> { - fn prepare_doc_solid( - &self, - body: KernelBody, - id: Option, - ) -> ApiResult { - let mut inner = match id { - Some(id) => match self.document().get_entity(obj_to_handle(id)) { - Some(EntityType::Solid3D(solid)) => solid.clone(), - _ => return Err(ApiError::UnknownId(id)), - }, - None => Solid3D::new(), - }; - let handle = id.map_or(Handle::NULL, obj_to_handle); - let display = self - .scene() - .prepare_solid_model_display(handle, &body) - .ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::Other, "solid display preparation failed") - })?; - let sat = acis_export::solid_to_sat(&body).ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::Acis, "solid serialization failed") - })?; - inner.wires = solid_model::edge_wires(&body); - inner.set_sat_document(&sat); - Ok(PreparedDocEntity { - entity: EntityType::Solid3D(inner), - solid: Some((body, display)), - }) - } - - fn prepare_doc_transform( - &mut self, - id: ObjectId, - placement: &PlacementSpec, - ) -> ApiResult { - self.ensure_transformable(id)?; - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - if matches!(entity, EntityType::Solid3D(_)) { - let body = self.resolve_body(id)?; - let moved = cadkernel::brep::transform(&body, &kernel_placement(placement)) - .ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed") - })?; - self.prepare_doc_solid(moved, Some(id)) - } else { - Ok(PreparedDocEntity { - entity: convert::transform_entity_geometry(entity, placement)?, - solid: None, - }) - } - } - - fn apply_doc_entity(&mut self, prepared: PreparedDocEntity, id: Option) -> ObjectId { - let handle = match id { - Some(id) => { - assert!( - self.scene_mut().update_entity(prepared.entity), - "prevalidated entity update" - ); - obj_to_handle(id) - } - None => self.scene_mut().add_entity(prepared.entity), - }; - if let Some((body, display)) = prepared.solid { - self.scene_mut() - .register_prepared_solid_model(handle, body, display); - } - handle_to_obj(handle) - } - - /// Kernel mesh mass properties, cached per entity and geometry epoch. - fn mass_properties(&mut self, id: ObjectId) -> ApiResult<(f64, [f64; 3])> { - let handle = obj_to_handle(id); - let epoch = self.scene().geometry_epoch; - if let Some(&(cached_epoch, v, c)) = self.scene().doc_api_mass_cache.get(&handle) { - if cached_epoch == epoch { - return Ok((v, c)); - } - } - if self.scene().doc_api_cold_tess_used >= 32 { - return Err(ApiError::validation( - "mass properties", - "request exceeds 32 uncached solid queries", - )); - } - let body = self.resolve_body(id)?; - self.scene_mut().doc_api_cold_tess_used += 1; - let mesh = cadkernel::brep::mesh_body(&body, 0.1, 1e-4); - let (v, c) = mesh.mass_properties().ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::Empty, "solid has no measurable volume") - })?; - if self.scene().doc_api_mass_cache.len() >= ocs_doc_api::ops::BULK_ITEM_CAP { - self.scene_mut().doc_api_mass_cache.clear(); - } - self.scene_mut() - .doc_api_mass_cache - .insert(handle, (epoch, v, c)); - Ok((v, c)) - } -} - -/// Validate a raster-image file path before embedding it in a document. -/// Rejects empty paths, UNC/network paths, parent-traversal, and non-image extensions. -fn validate_image_path(path: &str) -> ApiResult<()> { - let p = path.trim(); - if p.is_empty() { - return Err(ApiError::validation( - "CreateRasterImage", - "empty image file path", - )); - } - // UNC / network / device paths. - if p.starts_with("\\\\") || p.starts_with("//") { - return Err(ApiError::Unsupported( - "network/UNC image paths are not allowed".into(), - )); - } - // Parent-traversal anywhere in the path. - if p.split(['/', '\\']).any(|seg| seg == "..") { - return Err(ApiError::Unsupported( - "parent-traversal ('..') in image path is not allowed".into(), - )); - } - // Known image extension (last component). - let ext_ok = p - .rsplit('.') - .next() - .map(|ext| { - matches!( - ext.to_ascii_lowercase().as_str(), - "png" | "jpg" | "jpeg" | "bmp" | "gif" | "tif" | "tiff" | "webp" | "tga" - ) - }) - .unwrap_or(false); - if !ext_ok { - return Err(ApiError::validation( - "CreateRasterImage", - format!("unsupported image file extension in {path:?}"), - )); - } - Ok(()) -} - -fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement { - cadkernel::brep::Placement { - x_axis: p.x_axis, - y_axis: p.y_axis, - z_axis: p.z_axis, - origin: p.origin, - } -} - -// Conversions between ocs_doc_api plain-data DTOs and acadrust types. -// Kept in this file because they are host-side implementation details. - -fn cloning_flags_to_acadrust( - flags: ocs_doc_api::XRecordCloningFlags, -) -> acadrust::objects::DictionaryCloningFlags { - use acadrust::objects::DictionaryCloningFlags as F; - match flags { - ocs_doc_api::XRecordCloningFlags::NotApplicable => F::NotApplicable, - ocs_doc_api::XRecordCloningFlags::KeepExisting => F::KeepExisting, - ocs_doc_api::XRecordCloningFlags::UseClone => F::UseClone, - ocs_doc_api::XRecordCloningFlags::XrefName => F::XrefName, - ocs_doc_api::XRecordCloningFlags::Name => F::Name, - ocs_doc_api::XRecordCloningFlags::UnmangleName => F::UnmangleName, - } -} - -fn cloning_flags_from_acadrust( - flags: acadrust::objects::DictionaryCloningFlags, -) -> ocs_doc_api::XRecordCloningFlags { - use acadrust::objects::DictionaryCloningFlags as F; - match flags { - F::NotApplicable => ocs_doc_api::XRecordCloningFlags::NotApplicable, - F::KeepExisting => ocs_doc_api::XRecordCloningFlags::KeepExisting, - F::UseClone => ocs_doc_api::XRecordCloningFlags::UseClone, - F::XrefName => ocs_doc_api::XRecordCloningFlags::XrefName, - F::Name => ocs_doc_api::XRecordCloningFlags::Name, - F::UnmangleName => ocs_doc_api::XRecordCloningFlags::UnmangleName, - } -} - -fn xdata_value_to_acadrust( - value: &ocs_doc_api::XDataValue, - _doc: &acadrust::CadDocument, -) -> acadrust::xdata::XDataValue { - use acadrust::types::Vector3; - use ocs_doc_api::XDataValue as V; - match value { - V::String(s) => acadrust::xdata::XDataValue::String(s.clone()), - V::ControlString(s) => acadrust::xdata::XDataValue::ControlString(s.clone()), - V::LayerName(s) => acadrust::xdata::XDataValue::LayerName(s.clone()), - V::BinaryData(b) => acadrust::xdata::XDataValue::BinaryData(b.clone()), - V::Handle(h) => acadrust::xdata::XDataValue::Handle(acadrust::Handle::new(*h)), - V::Point3D(p) => acadrust::xdata::XDataValue::Point3D(Vector3::new(p[0], p[1], p[2])), - V::Position3D(p) => acadrust::xdata::XDataValue::Position3D(Vector3::new(p[0], p[1], p[2])), - V::Displacement3D(p) => { - acadrust::xdata::XDataValue::Displacement3D(Vector3::new(p[0], p[1], p[2])) - } - V::Direction3D(p) => acadrust::xdata::XDataValue::Direction3D(Vector3::new(p[0], p[1], p[2])), - V::Real(r) => acadrust::xdata::XDataValue::Real(*r), - V::Distance(d) => acadrust::xdata::XDataValue::Distance(*d), - V::ScaleFactor(s) => acadrust::xdata::XDataValue::ScaleFactor(*s), - V::Integer16(i) => acadrust::xdata::XDataValue::Integer16(*i), - V::Integer32(i) => acadrust::xdata::XDataValue::Integer32(*i), - } -} - -fn xdata_value_from_acadrust(value: &acadrust::xdata::XDataValue) -> ocs_doc_api::XDataValue { - use acadrust::xdata::XDataValue as V; - match value { - V::String(s) => ocs_doc_api::XDataValue::String(s.clone()), - V::ControlString(s) => ocs_doc_api::XDataValue::ControlString(s.clone()), - V::LayerName(s) => ocs_doc_api::XDataValue::LayerName(s.clone()), - V::BinaryData(b) => ocs_doc_api::XDataValue::BinaryData(b.clone()), - V::Handle(h) => ocs_doc_api::XDataValue::Handle(h.value()), - V::Point3D(v) => ocs_doc_api::XDataValue::Point3D([v.x, v.y, v.z]), - V::Position3D(v) => ocs_doc_api::XDataValue::Position3D([v.x, v.y, v.z]), - V::Displacement3D(v) => ocs_doc_api::XDataValue::Displacement3D([v.x, v.y, v.z]), - V::Direction3D(v) => ocs_doc_api::XDataValue::Direction3D([v.x, v.y, v.z]), - V::Real(r) => ocs_doc_api::XDataValue::Real(*r), - V::Distance(d) => ocs_doc_api::XDataValue::Distance(*d), - V::ScaleFactor(s) => ocs_doc_api::XDataValue::ScaleFactor(*s), - V::Integer16(i) => ocs_doc_api::XDataValue::Integer16(*i), - V::Integer32(i) => ocs_doc_api::XDataValue::Integer32(*i), - } -} - -fn xrecord_value_to_acadrust(value: &ocs_doc_api::XRecordValue) -> acadrust::objects::XRecordValue { - use ocs_doc_api::XRecordValue as V; - match value { - V::String(s) => acadrust::objects::XRecordValue::String(s.clone()), - V::Double(d) => acadrust::objects::XRecordValue::Double(*d), - V::Int16(i) => acadrust::objects::XRecordValue::Int16(*i), - V::Int32(i) => acadrust::objects::XRecordValue::Int32(*i), - V::Int64(i) => acadrust::objects::XRecordValue::Int64(*i), - V::Byte(b) => acadrust::objects::XRecordValue::Byte(*b), - V::Bool(b) => acadrust::objects::XRecordValue::Bool(*b), - V::Handle(h) => acadrust::objects::XRecordValue::Handle(acadrust::Handle::new(*h)), - V::Point3D(p) => acadrust::objects::XRecordValue::Point3D(p[0], p[1], p[2]), - V::Chunk(c) => acadrust::objects::XRecordValue::Chunk(c.clone()), - } -} - -fn xrecord_value_from_acadrust( - value: &acadrust::objects::XRecordValue, -) -> ocs_doc_api::XRecordValue { - use acadrust::objects::XRecordValue as V; - match value { - V::String(s) => ocs_doc_api::XRecordValue::String(s.clone()), - V::Double(d) => ocs_doc_api::XRecordValue::Double(*d), - V::Int16(i) => ocs_doc_api::XRecordValue::Int16(*i), - V::Int32(i) => ocs_doc_api::XRecordValue::Int32(*i), - V::Int64(i) => ocs_doc_api::XRecordValue::Int64(*i), - V::Byte(b) => ocs_doc_api::XRecordValue::Byte(*b), - V::Bool(b) => ocs_doc_api::XRecordValue::Bool(*b), - V::Handle(h) => ocs_doc_api::XRecordValue::Handle(h.value()), - V::Point3D(x, y, z) => ocs_doc_api::XRecordValue::Point3D([*x, *y, *z]), - V::Chunk(c) => ocs_doc_api::XRecordValue::Chunk(c.clone()), - } -} - -fn xrecord_entry_to_acadrust( - entry: &ocs_doc_api::XRecordEntry, -) -> acadrust::objects::XRecordEntry { - acadrust::objects::XRecordEntry { - code: entry.code, - value: xrecord_value_to_acadrust(&entry.value), - } -} - -fn xrecord_entry_from_acadrust( - entry: &acadrust::objects::XRecordEntry, -) -> ocs_doc_api::XRecordEntry { - ocs_doc_api::XRecordEntry { - code: entry.code, - value: xrecord_value_from_acadrust(&entry.value), - } -} - -// ── layer conversions ───────────────────────────────────────────────────── - -fn color_to_acadrust(c: Color) -> acadrust::types::Color { - use acadrust::types::Color as A; - match c { - Color::ByLayer => A::ByLayer, - Color::None => A::None, - Color::ByBlock => A::ByBlock, - Color::Index(i) => A::Index(i), - Color::Rgb { r, g, b } => A::Rgb { r, g, b }, - } -} - -fn color_from_acadrust(c: acadrust::types::Color) -> Color { - use acadrust::types::Color as A; - match c { - A::ByLayer => Color::ByLayer, - A::None => Color::None, - A::ByBlock => Color::ByBlock, - A::Index(i) => Color::Index(i), - A::Rgb { r, g, b } => Color::Rgb { r, g, b }, - } -} - -fn line_weight_to_acadrust(lw: LineWeight) -> acadrust::types::LineWeight { - use acadrust::types::LineWeight as A; - match lw { - LineWeight::ByLayer => A::ByLayer, - LineWeight::ByBlock => A::ByBlock, - LineWeight::Default => A::Default, - LineWeight::Value(v) => A::Value(v), - } -} - -fn line_weight_from_acadrust(lw: acadrust::types::LineWeight) -> LineWeight { - use acadrust::types::LineWeight as A; - match lw { - A::ByLayer => LineWeight::ByLayer, - A::ByBlock => LineWeight::ByBlock, - A::Default => LineWeight::Default, - A::Value(v) => LineWeight::Value(v), - } -} - -fn layer_flags_to_acadrust(f: LayerFlags) -> acadrust::tables::LayerFlags { - acadrust::tables::LayerFlags { - frozen: f.frozen, - locked: f.locked, - frozen_in_new_viewport: f.frozen_in_new_viewport, - off: f.off, - xref_dependent: false, - } -} - -fn layer_flags_from_acadrust(f: acadrust::tables::LayerFlags) -> LayerFlags { - LayerFlags { - frozen: f.frozen, - locked: f.locked, - frozen_in_new_viewport: f.frozen_in_new_viewport, - off: f.off, - } -} - -fn apply_layer_info(layer: &mut acadrust::tables::Layer, info: &LayerInfo) { - layer.name = info.name.trim().to_string(); - layer.flags = layer_flags_to_acadrust(info.flags); - layer.color = color_to_acadrust(info.color); - layer.color_name = None; - layer.book_name = None; - layer.line_type = info.line_type.clone(); - layer.line_weight = line_weight_to_acadrust(info.line_weight); - layer.plot_style = info.plot_style.clone(); - layer.is_plottable = info.is_plottable; -} - -fn layer_info_from_acadrust(layer: &acadrust::tables::Layer) -> LayerInfo { - LayerInfo { - name: layer.name.clone(), - flags: layer_flags_from_acadrust(layer.flags), - color: color_from_acadrust(layer.color), - line_type: layer.line_type.clone(), - line_weight: line_weight_from_acadrust(layer.line_weight), - plot_style: layer.plot_style.clone(), - is_plottable: layer.is_plottable, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app::plugin_host::HostSession; - use crate::app::OpenCADStudio; - use ocs_doc_api::ops::{ - BoolOp, Color, LayerFlags, LayerInfo, LineWeight, SolidPrimitive, XDataRecord, - XDataValue, XRecordEntry, XRecordSpec, XRecordValue, - }; - use ocs_doc_api::{ - DocApiEnvelope, HasId, ObjectId, Operation, Query, QueryResult, Receipt, - XRecordCloningFlags, - }; - - fn dispatch(host: &mut HostSession<'_>, env: DocApiEnvelope) -> ApiResult { - let bytes = bincode::serialize(&env).unwrap(); - let tab_id = host.tab_id(); - let out = execute_doc_api(host, tab_id, &bytes).expect("dispatch failed"); - bincode::deserialize(&out).expect("receipt deserialize") - } - - fn new_id(receipt: &Receipt) -> ObjectId { - receipt - .outcome - .as_ref() - .and_then(|o| o.new_id()) - .expect("no new id") - } - - #[test] - fn doc_api_create_boolean_query_end_to_end() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let rev0 = host.scene().geometry_epoch; - - // op 1 + 2: create two overlapping boxes (one undo step + one bump each). - let cuboid_a = Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [10.0; 3], - }); - let cuboid_b = Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [5.0; 3], - size: [10.0; 3], - }); - let a = new_id(&dispatch(&mut host, DocApiEnvelope::op(cuboid_a)).unwrap()); - let b = new_id(&dispatch(&mut host, DocApiEnvelope::op(cuboid_b)).unwrap()); - // Each write op advanced the geometry epoch (at least one bump per op). - assert!( - host.scene().geometry_epoch > rev0, - "epoch advanced by creates" - ); - - // op 3: intersect; erase_sources keeps the result at `a`, erases `b`. - let intersect = Operation::SolidBoolean { - op: BoolOp::Intersection, - a, - b, - erase_sources: true, - }; - let lens = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect)).unwrap()); - assert_eq!(lens, a); - assert!( - host.document().get_entity(obj_to_handle(b)).is_none(), - "b erased" - ); - assert!( - host.scene().solid_models.contains_key(&obj_to_handle(a)), - "result in cache" - ); - - // query batch: bounds + volume (read-only, no bump). - let rev_before_query = host.scene().geometry_epoch; - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: a }, Query::GetVolume { id: a }]), - ) - .unwrap(); - assert_eq!( - host.scene().geometry_epoch, - rev_before_query, - "queries must not bump" - ); - let bb = match &receipt.query_results[0] { - QueryResult::Bounds(bb) => *bb, - other => panic!("expected bounds, got {other:?}"), - }; - // [0,10]^3 intersect [5,15]^3 = [5,10]^3. - assert!( - (bb.min[0] - 5.0).abs() < 1e-4 && (bb.max[0] - 10.0).abs() < 1e-4, - "bounds {bb:?}" - ); - let vol = match &receipt.query_results[1] { - QueryResult::Volume(v) => *v, - other => panic!("expected volume, got {other:?}"), - }; - assert!((vol - 125.0).abs() < 1.0, "volume {vol}"); - let _ = HasId::id(&lens); - } - - #[test] - fn doc_api_transform_many_with_stale_id_fails_all_or_nothing() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // A transformable solid, plus a stale id (always non-transformable -> UnknownId). - let mk_solid = Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [10.0; 3], - }); - let solid = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_solid)).unwrap()); - let stale = ObjectId::from_u64(0xFFFF_FFFF); - let rev_before = host.scene().geometry_epoch; - - // TransformMany over [solid, stale]: the stale id makes it fail BEFORE any - // mutation (all-or-nothing), so the epoch must not move and no undo is recorded. - let op = Operation::TransformMany { - ids: vec![solid, stale], - placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]), - }; - let err = dispatch(&mut host, DocApiEnvelope::op(op)).unwrap_err(); - assert!( - matches!(err, ApiError::Validation { .. } | ApiError::UnknownId(_)), - "{err:?}" - ); - assert_eq!( - host.scene().geometry_epoch, - rev_before, - "no mutation on rejected TransformMany" - ); - } - - #[test] - fn doc_api_query_batch_over_cap_is_rejected() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let over = ocs_doc_api::ops::BULK_ITEM_CAP + 1; - let queries: Vec = (0..over).map(|_| Query::GetGeometryRevision).collect(); - let err = dispatch(&mut host, DocApiEnvelope::queries(queries)).unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); - } - - // ── Phase 0: outstanding supported-family methods ────────────────────── - - #[test] - fn phase0_add_vertex_to_polyline() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let mk_poly = Operation::CreateCurve(Curve2Spec::Polyline { layer: None, - points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]], - closed: false, - }); - let poly = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_poly)).unwrap()); - // Insert a vertex at index 1. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::AddVertex { - id: poly, - at: 1, - point: [5.0, 0.0, 0.0], - }), - ) - .unwrap(); - // The polyline now has 4 vertices with the inserted one at index 1. - let handle = obj_to_handle(poly); - let Some(EntityType::LwPolyline(pl)) = host.document().get_entity(handle) else { - panic!("polyline not found"); - }; - assert_eq!(pl.vertices.len(), 4); - assert!((pl.vertices[1].location.x - 5.0).abs() < 1e-9); - // Out-of-range index is a validation error, not a panic. - let err = dispatch( - &mut host, - DocApiEnvelope::op(Operation::AddVertex { - id: poly, - at: 99, - point: [0.0, 0.0, 0.0], - }), - ) - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); - } - - #[test] - fn phase0_extrude_rectangular_profile_to_solid() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // A closed rectangular 2x3 profile in XY. - let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { layer: None, - points: vec![ - [0.0, 0.0, 0.0], - [2.0, 0.0, 0.0], - [2.0, 3.0, 0.0], - [0.0, 3.0, 0.0], - ], - closed: true, - }); - let profile = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_profile)).unwrap()); - // Extrude +Z by 5 -> a 2x3x5 box = volume 30. - let solid = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::Extrude { - profile, - direction: [0.0, 0.0, 5.0], - }), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetVolume { id: solid }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Volume(v) => assert!((*v - 30.0).abs() < 1.0, "extrude volume {v}"), - other => panic!("expected volume, got {other:?}"), - } - } - - #[test] - fn phase0_revolve_profile_to_solid() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // A 1-wide, 2-tall rectangle offset 1 from the Y axis; revolve about the Y - // axis by 2*pi -> a cylinder-ish annulus (outer r=2, inner r=1, h=2): pi*(4-1)*2 = 6pi ≈ 18.85. - let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { layer: None, - points: vec![ - [1.0, 0.0, 0.0], - [2.0, 0.0, 0.0], - [2.0, 2.0, 0.0], - [1.0, 2.0, 0.0], - ], - closed: true, - }); - let profile = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_profile)).unwrap()); - let result = dispatch( - &mut host, - DocApiEnvelope::op(Operation::Revolve { - profile, - axis: ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0]), - angle: std::f64::consts::TAU, - }), - ); - // Revolve may be geometry-sensitive; assert it either produces a positive - // volume or surfaces a structured geometry error (no panic). - match result { - Ok(receipt) => { - let id = receipt.outcome.and_then(|o| o.new_id()).unwrap(); - let v = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetVolume { id }]), - ) - .unwrap(); - match &v.query_results[0] { - QueryResult::Volume(vol) => assert!(*vol > 0.0, "revolve volume {vol}"), - other => panic!("expected volume, got {other:?}"), - } - } - Err(ApiError::Geometry { .. } | ApiError::Validation { .. }) => {} - Err(e) => panic!("unexpected error: {e}"), - } - } - - #[test] - fn phase0_non_solid_transform_line_and_circle() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let mk_line = Operation::CreateCurve(Curve2Spec::Line { layer: None, - start: [0.0; 3], - end: [10.0; 3], - }); - let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_line)).unwrap()); - // Translate the line by +5 in X: bounds shift by +5. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: line, - placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]), - }), - ) - .unwrap(); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: line }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 5.0).abs() < 1e-6 && (b.max[0] - 15.0).abs() < 1e-6, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - // TransformMany over a mix of line + circle is now all-or-nothing (both transformable). - let circle = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, - centre: [0.0; 3], - radius: 2.0, - })), - ) - .unwrap(), - ); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::TransformMany { - ids: vec![line, circle], - placement: ocs_doc_api::PlacementSpec::at([0.0, 1.0, 0.0]), - }), - ) - .unwrap(); - } - - // ── Phase 2: full 2D curve families ──────────────────────────────────── - - #[test] - fn phase2_arc_ellipse_spline_create_bounds_transform() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - - // Arc: create -> kind Arc; bounds are the coarse full-circle bounds. - let arc = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { layer: None, - centre: [5.0, 5.0, 0.0], - radius: 4.0, - start_angle: 0.0, - end_angle: std::f64::consts::PI, - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetEntity { id: arc }, - Query::GetBounds { id: arc }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Entity(v) => assert_eq!(v.kind, "Arc"), - other => panic!("expected entity, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Bounds(b) => assert!( - (b.min[0] - 1.0).abs() < 1e-6 && (b.max[0] - 9.0).abs() < 1e-6, - "{b:?}" - ), - other => panic!("expected bounds, got {other:?}"), - } - - // Ellipse: create -> kind Ellipse; bounds = centre ± major-axis length. - let ell = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { layer: None, - centre: [0.0, 0.0, 0.0], - major_axis: [6.0, 0.0, 0.0], - ratio: 0.5, - start: 0.0, - end: std::f64::consts::TAU, - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetEntity { id: ell }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Entity(v) => assert_eq!(v.kind, "Ellipse"), - other => panic!("expected entity, got {other:?}"), - } - - // Spline (degree-3 cubic through 4 control points): create -> kind Spline; - // bounds = control-point bounds. Transform by +10 X shifts bounds. - let spline = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Spline { layer: None, - degree: 3, - control_points: vec![ - [0.0, 0.0, 0.0], - [1.0, 2.0, 0.0], - [2.0, -2.0, 0.0], - [3.0, 0.0, 0.0], - ], - knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], - weights: vec![1.0; 4], - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: spline }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(b) => assert!( - (b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 3.0).abs() < 1e-6, - "{b:?}" - ), - other => panic!("expected bounds, got {other:?}"), - } - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: spline, - placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]), - }), - ) - .unwrap(); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: spline }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(b) => assert!((b.min[0] - 10.0).abs() < 1e-6, "{b:?}"), - other => panic!("expected bounds, got {other:?}"), - } - } - - #[test] - fn phase2_ray_xline_create_and_transform() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let ray = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { layer: None, - origin: [1.0, 2.0, 0.0], - direction: [1.0, 0.0, 0.0], - })), - ) - .unwrap(), - ); - let xline = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::XLine { layer: None, - origin: [0.0, 0.0, 0.0], - direction: [0.0, 1.0, 0.0], - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetEntity { id: ray }, - Query::GetEntity { id: xline }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Entity(v) => assert_eq!(v.kind, "Ray"), - other => panic!("expected entity, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Entity(v) => assert_eq!(v.kind, "XLine"), - other => panic!("expected entity, got {other:?}"), - } - // Ray/XLine are unbounded -> GetBounds is Unsupported (not a panic). - assert!(dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: ray }]) - ) - .is_err()); - // Transform moves the ray's base point (no error). - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: ray, - placement: ocs_doc_api::PlacementSpec::at([0.0, 0.0, 5.0]), - }), - ) - .unwrap(); - let handle = obj_to_handle(ray); - let Some(EntityType::Ray(r)) = host.document().get_entity(handle) else { - panic!("ray not found") - }; - assert!((r.base_point.z - 5.0).abs() < 1e-6); - } - - // ── Phase 4: paper-space viewports ─────────────────────────────────────── - - #[test] - fn phase4_create_viewport_bounds_transform() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // A 40x30 paper-space viewport at (50,50,0) looking at model origin. - let vp = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateViewport(ocs_doc_api::ops::ViewportSpec { - center: [50.0, 50.0, 0.0], - width: 40.0, - height: 30.0, - view_target: [0.0, 0.0, 0.0], - view_height: 100.0, - })), - ) - .unwrap(), - ); - let handle = obj_to_handle(vp); - let Some(EntityType::Viewport(v)) = host.document().get_entity(handle) else { - panic!("viewport not found") - }; - assert!((v.width - 40.0).abs() < 1e-9 && (v.view_height - 100.0).abs() < 1e-9); - - // Bounds = center ± width/2, height/2. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetEntity { id: vp }, - Query::GetBounds { id: vp }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Entity(e) => assert_eq!(e.kind, "Viewport"), - other => panic!("expected entity, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 30.0).abs() < 1e-6 && (b.max[0] - 70.0).abs() < 1e-6, - "{b:?}" - ); - assert!( - (b.min[1] - 35.0).abs() < 1e-6 && (b.max[1] - 65.0).abs() < 1e-6, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - - // Transform moves the viewport's paper-space center. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: vp, - placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]), - }), - ) - .unwrap(); - let Some(EntityType::Viewport(v)) = host.document().get_entity(handle) else { - panic!("viewport not found") - }; - assert!((v.center.x - 60.0).abs() < 1e-6); - } - - // ── Regression tests for review fixes (s^2 scale, rotation, all-or-nothing) ── - - #[test] - fn fix_uniform_scale_applied_once_not_squared() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let circle = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, - centre: [0.0, 0.0, 0.0], - radius: 1.0, - })), - ) - .unwrap(), - ); - // Uniform scale by 2 (x_axis=(2,0,0) etc). Radius must become 2 (s), not 4 (s^2). - let placement = ocs_doc_api::PlacementSpec { - x_axis: [2.0, 0.0, 0.0], - y_axis: [0.0, 2.0, 0.0], - z_axis: [0.0, 0.0, 2.0], - origin: [0.0; 3], - }; - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: circle, - placement, - }), - ) - .unwrap(); - let handle = obj_to_handle(circle); - let Some(EntityType::Circle(c)) = host.document().get_entity(handle) else { - panic!("circle not found") - }; - assert!( - (c.radius - 2.0).abs() < 1e-9, - "radius {} (must be 2.0, not 4.0)", - c.radius - ); - } - - #[test] - fn fix_rotation_rotates_ellipse_ray_and_insert() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // 90-degree Z-rotation: x_axis=(0,1,0), y_axis=(-1,0,0). - let rot90 = ocs_doc_api::PlacementSpec { - x_axis: [0.0, 1.0, 0.0], - y_axis: [-1.0, 0.0, 0.0], - z_axis: [0.0, 0.0, 1.0], - origin: [0.0; 3], - }; - // Ray along +X must become along +Y after a 90° rotation. - let ray = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { layer: None, - origin: [0.0, 0.0, 0.0], - direction: [1.0, 0.0, 0.0], - })), - ) - .unwrap(), - ); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: ray, - placement: rot90, - }), - ) - .unwrap(); - let Some(EntityType::Ray(r)) = host.document().get_entity(obj_to_handle(ray)) else { - panic!("ray not found") - }; - assert!( - (r.direction.y - 1.0).abs() < 1e-6 && r.direction.x.abs() < 1e-6, - "ray dir {:?}", - r.direction - ); - - // Ellipse major_axis along +X must rotate to +Y (length preserved). - let ell = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { layer: None, - centre: [0.0; 3], - major_axis: [4.0, 0.0, 0.0], - ratio: 0.5, - start: 0.0, - end: std::f64::consts::TAU, - })), - ) - .unwrap(), - ); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: ell, - placement: rot90, - }), - ) - .unwrap(); - let Some(EntityType::Ellipse(e)) = host.document().get_entity(obj_to_handle(ell)) else { - panic!("ellipse not found") - }; - assert!( - (e.major_axis.y - 4.0).abs() < 1e-6 && e.major_axis.x.abs() < 1e-6, - "ellipse axis {:?}", - e.major_axis - ); - - // Arc angles offset by +90° (π/2). - let arc = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { layer: None, - centre: [0.0; 3], - radius: 2.0, - start_angle: 0.0, - end_angle: 1.0, - })), - ) - .unwrap(), - ); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: arc, - placement: rot90, - }), - ) - .unwrap(); - let Some(EntityType::Arc(a)) = host.document().get_entity(obj_to_handle(arc)) else { - panic!("arc not found") - }; - let half_pi = std::f64::consts::FRAC_PI_2; - assert!( - (a.start_angle - half_pi).abs() < 1e-6, - "arc start {} (expected {})", - a.start_angle, - half_pi - ); - } - - #[test] - fn fix_transform_many_locked_layer_fails_all_or_nothing() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let a = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, - position: [0.0; 3], - })), - ) - .unwrap(), - ); - let b = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, - position: [1.0; 3], - })), - ) - .unwrap(), - ); - // Lock the layer holding `b`. - let bh = obj_to_handle(b); - let layer = host - .document() - .get_entity(bh) - .unwrap() - .common() - .layer - .clone(); - host.document_mut().layers.get_mut(&layer).unwrap().lock(); - let rev_before = host.scene().geometry_epoch; - - // TransformMany [a, b] must fail all-or-nothing (b is locked) BEFORE mutating a. - let err = dispatch( - &mut host, - DocApiEnvelope::op(Operation::TransformMany { - ids: vec![a, b], - placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]), - }), - ) - .unwrap_err(); - assert!( - matches!( - err, - ApiError::Validation { .. } | ApiError::Unsupported(_) | ApiError::UnknownId(_) - ), - "{err:?}" - ); - // `a` was NOT transformed (all-or-nothing): its location is unchanged. - let Some(EntityType::Point(pa)) = host.document().get_entity(obj_to_handle(a)) else { - panic!("point a not found") - }; - assert!( - (pa.location.x - 0.0).abs() < 1e-9, - "a moved despite locked batch member" - ); - assert_eq!( - host.scene().geometry_epoch, - rev_before, - "no mutation on rejected TransformMany" - ); - } - - // ── Phase 2b-a: annotations (Text/MText) ───────────────────────────────── - - #[test] - fn phase2b_text_mtext_create_content_transform() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - - // Create a TEXT, read its content, set it, verify. - let text = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateText(ocs_doc_api::ops::TextSpec { - value: "hello".into(), - insertion_point: [1.0, 2.0, 0.0], - height: 2.5, - rotation: 0.0, - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetTextContent { id: text }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::TextContent(s) => assert_eq!(s, "hello"), - other => panic!("expected content, got {other:?}"), - } - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetTextContent { - id: text, - value: "world".into(), - }), - ) - .unwrap(); - let Some(EntityType::Text(t)) = host.document().get_entity(obj_to_handle(text)) else { - panic!("text not found") - }; - assert_eq!(t.value, "world"); - - // SetTextContent on a non-text entity is Unsupported. - let line = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, - start: [0.0; 3], - end: [1.0; 3], - })), - ) - .unwrap(), - ); - assert!(dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetTextContent { - id: line, - value: "x".into() - }) - ) - .is_err()); - - // MTEXT create + content + transform (insertion point moves). - let mtext = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateMText(ocs_doc_api::ops::MTextSpec { - value: "multi\nline".into(), - insertion_point: [5.0, 5.0, 0.0], - height: 3.0, - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetTextContent { id: mtext }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::TextContent(s) => assert_eq!(s, "multi\nline"), - other => panic!("expected content, got {other:?}"), - } - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: mtext, - placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]), - }), - ) - .unwrap(); - let Some(EntityType::MText(t)) = host.document().get_entity(obj_to_handle(mtext)) else { - panic!("mtext not found") - }; - assert!((t.insertion_point.x - 15.0).abs() < 1e-6); - } - - // ── Outstanding methods: loft + bulge-arc profiles ─────────────────────── - - #[test] - fn loft_two_profiles_produces_solid() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Two circles at different Z (profiles for loft). - let c1 = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, - centre: [0.0, 0.0, 0.0], - radius: 5.0, - })), - ) - .unwrap(), - ); - let c2 = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, - centre: [0.0, 0.0, 10.0], - radius: 2.0, - })), - ) - .unwrap(), - ); - let result = dispatch( - &mut host, - DocApiEnvelope::op(Operation::Loft { - profiles: vec![c1, c2], - }), - ); - let id = new_id(&result.expect("loft must preserve the sections' elevations")); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetVolume { id }, Query::GetBounds { id }]), - ) - .unwrap(); - let QueryResult::Volume(volume) = receipt.query_results[0] else { - panic!("volume") - }; - let expected = std::f64::consts::PI * 10.0 * 39.0 / 3.0; - assert!( - (volume - expected).abs() < expected * 0.05, - "volume {volume}" - ); - let QueryResult::Bounds(bounds) = receipt.query_results[1] else { - panic!("bounds") - }; - assert!((bounds.min[2]).abs() < 1e-4 && (bounds.max[2] - 10.0).abs() < 1e-4); - } - - #[test] - fn bulge_arc_polyline_profile_converts_to_arc() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // A polyline with a bulge (arc) segment: square with one curved side. - let poly = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { layer: None, - points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]], - closed: false, - })), - ) - .unwrap(), - ); - // Add a bulge to the first segment by editing the vertex. - let handle = obj_to_handle(poly); - let Some(EntityType::LwPolyline(mut pl)) = host.document().get_entity(handle).cloned() - else { - panic!("polyline not found") - }; - pl.vertices[0].bulge = 1.0; // 90-degree arc (tan(π/2/4) = 1) - host.scene_mut().update_entity(EntityType::LwPolyline(pl)); - - // profile_curves now converts the bulge to a Curve::Arc (not Unsupported). - let curves = host - .profile_curves(poly) - .expect("bulge profile must convert"); - // Open polyline, 3 vertices -> 2 segments: arc (bulged first), line. - assert_eq!(curves.len(), 2, "2 segments (arc + line)"); - assert!( - matches!(curves[0], cadkernel::geom2d::Curve::Arc(_)), - "first segment is an arc" - ); - assert!( - matches!(curves[1], cadkernel::geom2d::Curve::Line(_)), - "second is a line" - ); - // bulge=1 -> included angle θ = 4·atan(1) = π (a semicircle); radius = chord/2 = 5. - if let cadkernel::geom2d::Curve::Arc(arc) = &curves[0] { - assert!( - (arc.radius - 5.0).abs() < 0.01, - "arc radius {} (semicircle)", - arc.radius - ); - } - - // Negative (CW) bulge: the center must be on the CW side (mirror of CCW). - // A single 90° bulge segment start=(0,0), end=(1,0), bulge=-0.4142 (~90° CW): - // correct center is BELOW the chord; the buggy double-sign put it above. - let neg = convert::bulge_arc_segment([0.0, 0.0], [1.0, 0.0], -0.4142) - .expect("negative bulge arc"); - if let cadkernel::geom2d::Curve::Arc(arc) = &neg { - // 90° arc, chord 1: radius = 0.5/sin(45°) ≈ 0.7071; center y must be negative. - assert!( - (arc.radius - std::f64::consts::FRAC_1_SQRT_2).abs() < 0.01, - "cw arc radius {}", - arc.radius - ); - assert!( - arc.centre[1] < 0.0, - "CW arc center y must be below the chord, got {:?}", - arc.centre - ); - } else { - panic!("expected an arc for negative bulge"); - } - // And the CCW mirror: center above. - let pos = - convert::bulge_arc_segment([0.0, 0.0], [1.0, 0.0], 0.4142).expect("positive bulge arc"); - if let cadkernel::geom2d::Curve::Arc(arc) = &pos { - assert!( - arc.centre[1] > 0.0, - "CCW arc center y must be above the chord, got {:?}", - arc.centre - ); - } - } - - // ── Phase 2c-ii: dimension sub-types (radius/diameter/angular) ────────── - - #[test] - fn phase2cii_dimension_radius_diameter_angular() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Radius: center (0,0,0), point on circle (4,0,0) -> radius 4. - let rad = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateDimensionRadius( - ocs_doc_api::ops::DimensionRadialSpec { - center: [0.0; 3], - point: [4.0, 0.0, 0.0], - }, - )), - ) - .unwrap(), - ); - // Diameter: chord points (0,0,0) and (6,0,0) -> diameter 6. - let dia = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateDimensionDiameter( - ocs_doc_api::ops::DimensionRadialSpec { - center: [0.0; 3], - point: [6.0, 0.0, 0.0], - }, - )), - ) - .unwrap(), - ); - // Angular: vertex (0,0,0), leg points (10,0,0) and (0,10,0) -> 90 degrees. - let ang = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateDimensionAngular( - ocs_doc_api::ops::DimensionAngularSpec { - vertex: [0.0; 3], - first_point: [10.0, 0.0, 0.0], - second_point: [0.0, 10.0, 0.0], - arc_location: [5.0, 5.0, 0.0], - }, - )), - ) - .unwrap(), - ); - - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetDimensionMeasurement { id: rad }, - Query::GetDimensionMeasurement { id: dia }, - Query::GetDimensionMeasurement { id: ang }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::DimensionMeasurement(v) => assert!((*v - 4.0).abs() < 1e-4, "radius {v}"), - other => panic!("expected measurement, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::DimensionMeasurement(v) => { - assert!((*v - 6.0).abs() < 1e-4, "diameter {v}") - } - other => panic!("expected measurement, got {other:?}"), - } - match &receipt.query_results[2] { - QueryResult::DimensionMeasurement(v) => assert!((*v - 90.0).abs() < 0.1, "angle {v}"), - other => panic!("expected measurement, got {other:?}"), - } - } - - // ── Phase 3-ii: typed AttributeDefinition create ────────────────────────── - - #[test] - fn phase3ii_create_attribute_definition() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let attdef = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateAttributeDefinition( - ocs_doc_api::ops::AttributeDefinitionSpec { - tag: "DOOR_NO".into(), - prompt: "Door number?".into(), - default_value: "D-1".into(), - insertion_point: [5.0, 5.0, 0.0], - height: 2.5, - rotation: 0.0, - }, - )), - ) - .unwrap(), - ); - let handle = obj_to_handle(attdef); - let Some(EntityType::AttributeDefinition(a)) = host.document().get_entity(handle) else { - panic!("attdef not found") - }; - assert_eq!(a.tag, "DOOR_NO"); - assert_eq!(a.default_value, "D-1"); - assert!((a.height - 2.5).abs() < 1e-9); - // GetEntity reports the AttributeDefinition kind. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetEntity { id: attdef }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Entity(e) => assert_eq!(e.kind, "AttributeDefinition"), - other => panic!("expected entity, got {other:?}"), - } - } - - // ── Phase 5-ii: typed Table create ─────────────────────────────────────── - - #[test] - fn phase5ii_create_table_from_grid() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let data = vec![ - vec!["Name".to_string(), "Value".to_string()], - vec!["Length".to_string(), "100.0".to_string()], - vec!["Width".to_string(), "50.0".to_string()], - ]; - let table = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateTable(ocs_doc_api::ops::TableSpec { - insertion_point: [10.0, 10.0, 0.0], - data: data.clone(), - })), - ) - .unwrap(), - ); - let handle = obj_to_handle(table); - let Some(EntityType::Table(t)) = host.document().get_entity(handle) else { - panic!("table not found") - }; - assert_eq!(t.rows.len(), 3); - assert_eq!(t.columns.len(), 2); - // Non-rectangular grid is a validation error. - let bad = vec![ - vec!["a".to_string()], - vec!["b".to_string(), "c".to_string()], - ]; - assert!(dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateTable(ocs_doc_api::ops::TableSpec { - insertion_point: [0.0; 3], - data: bad - })) - ) - .is_err()); - // GetEntity reports kind Table. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetEntity { id: table }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Entity(e) => assert_eq!(e.kind, "Table"), - other => panic!("expected entity, got {other:?}"), - } - } - - // ── Outstanding method: accurate volume/centroid (fine tessellation) ───── - - #[test] - fn accurate_volume_centroid_sphere_and_cube() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Sphere r=5: analytic volume = 4/3 * pi * 125 ≈ 523.599; centroid at centre. - let ball = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Sphere { - centre: [10.0, 20.0, 30.0], - radius: 5.0, - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetVolume { id: ball }, - Query::GetCentroid { id: ball }, - ]), - ) - .unwrap(); - let analytic = 4.0 / 3.0 * std::f64::consts::PI * 125.0; - match &receipt.query_results[0] { - QueryResult::Volume(v) => { - let err = ((*v - analytic) / analytic).abs(); - assert!( - err < 0.005, - "sphere volume {v} vs analytic {analytic} (err {err:.4})" - ); - } - other => panic!("expected volume, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Centroid(c) => { - assert!( - (c[0] - 10.0).abs() < 0.05 - && (c[1] - 20.0).abs() < 0.05 - && (c[2] - 30.0).abs() < 0.05, - "sphere centroid {c:?}" - ); - } - other => panic!("expected centroid, got {other:?}"), - } - // Cube 10^3 is exact (planar faces: divergence is exact regardless of LOD). - let cube = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [10.0; 3], - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetVolume { id: cube }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Volume(v) => assert!((*v - 1000.0).abs() < 1.0, "cube volume {v}"), - other => panic!("expected volume, got {other:?}"), - } - } - - // ── Phase 2c-iii: 2-line angular dimension ──────────────────────────────── - - #[test] - fn phase2ciii_dimension_angular2ln() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Angle between line (0,0,0)->(10,0,0) and (0,0,0)->(0,10,0) = 90 degrees. - let ang = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateDimensionAngular2Ln( - ocs_doc_api::ops::DimensionAngularSpec { - vertex: [0.0; 3], - first_point: [10.0, 0.0, 0.0], - second_point: [0.0, 10.0, 0.0], - arc_location: [5.0, 5.0, 0.0], - }, - )), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetDimensionMeasurement { id: ang }, - Query::GetEntity { id: ang }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::DimensionMeasurement(v) => assert!((*v - 90.0).abs() < 0.1, "angle {v}"), - other => panic!("expected measurement, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Entity(e) => assert_eq!(e.kind, "Dimension"), - other => panic!("expected entity, got {other:?}"), - } - } - - // ── Review-fix regression tests (Loft cap, CreateMany validation, Ellipse - // rotation, raster path validation) ───────────────────────────────────── - - #[test] - fn fix_loft_over_cap_is_rejected() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let over = ocs_doc_api::ops::BULK_ITEM_CAP + 1; - let profiles: Vec = (0..over).map(|i| ObjectId::from_u64(i as u64)).collect(); - let err = - dispatch(&mut host, DocApiEnvelope::op(Operation::Loft { profiles })).unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); - } - - #[test] - fn fix_create_many_invalid_curve_is_all_or_nothing() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let rev_before = host.scene().geometry_epoch; - // A valid point + an invalid ellipse (ratio > 1) in one CreateMany batch. - let specs = vec![ - ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Point { layer: None, position: [0.0; 3] }), - ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Ellipse { layer: None, - centre: [0.0; 3], - major_axis: [1.0; 3], - ratio: 2.0, - start: 0.0, - end: std::f64::consts::TAU, - }), - ]; - let err = - dispatch(&mut host, DocApiEnvelope::op(Operation::CreateMany(specs))).unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); - // All-or-nothing: NO entity was created (the point would have been added first). - assert_eq!( - host.scene().geometry_epoch, - rev_before, - "no mutation on rejected CreateMany" - ); - } - - #[test] - fn partial_ellipse_rotation_preserves_parameters() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Rotating the major axis already rotates the ellipse; its parameters stay fixed. - let ell = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { layer: None, - centre: [0.0; 3], - major_axis: [4.0, 0.0, 0.0], - ratio: 0.5, - start: 0.0, - end: std::f64::consts::FRAC_PI_2, - })), - ) - .unwrap(), - ); - let rot90 = ocs_doc_api::PlacementSpec { - x_axis: [0.0, 1.0, 0.0], - y_axis: [-1.0, 0.0, 0.0], - z_axis: [0.0, 0.0, 1.0], - origin: [0.0; 3], - }; - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: ell, - placement: rot90, - }), - ) - .unwrap(); - let Some(EntityType::Ellipse(e)) = host.document().get_entity(obj_to_handle(ell)) else { - panic!("ellipse not found") - }; - let half_pi = std::f64::consts::FRAC_PI_2; - assert!(e.start_parameter.abs() < 1e-6); - assert!((e.end_parameter - half_pi).abs() < 1e-6); - // major_axis rotated from +X to +Y. - assert!( - (e.major_axis.y - 4.0).abs() < 1e-6, - "major_axis {:?}", - e.major_axis - ); - } - - #[test] - fn fix_raster_image_path_validation() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let mk = |path: &str| { - Operation::CreateRasterImage(ocs_doc_api::ops::RasterImageSpec { - file_path: path.into(), - insertion_point: [0.0; 3], - u_vector: [0.5, 0.0, 0.0], - v_vector: [0.0, 0.5, 0.0], - size: [10.0, 10.0], - }) - }; - // Valid path succeeds. - assert!(dispatch(&mut host, DocApiEnvelope::op(mk("C:/img/photo.png"))).is_ok()); - // UNC path rejected. - assert!(dispatch( - &mut host, - DocApiEnvelope::op(mk("\\\\server\\share\\x.png")) - ) - .is_err()); - // Parent-traversal rejected. - assert!(dispatch( - &mut host, - DocApiEnvelope::op(mk("C:/img/../../etc/passwd.png")) - ) - .is_err()); - // Non-image extension rejected. - assert!(dispatch(&mut host, DocApiEnvelope::op(mk("C:/img/evil.exe"))).is_err()); - // Empty path rejected. - assert!(dispatch(&mut host, DocApiEnvelope::op(mk(""))).is_err()); - } - - // ── Read-mostly family bounds (Leader/Mesh/Face3D/MLine/Helix) ────────── - - #[test] - fn read_mostly_family_bounds_leader_and_mesh() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Leader with vertices at (0,0,0),(5,5,0),(10,0,0) -> bounds x[0,10] y[0,5]. - let leader = acadrust::entities::Leader { - vertices: vec![ - acadrust::types::Vector3::new(0.0, 0.0, 0.0), - acadrust::types::Vector3::new(5.0, 5.0, 0.0), - acadrust::types::Vector3::new(10.0, 0.0, 0.0), - ], - ..Default::default() - }; - let leader_h = host - .document_mut() - .add_entity(EntityType::Leader(leader)) - .unwrap(); - // Mesh with vertices in a unit cube at (2..3, 2..3, 0). - let mesh = acadrust::entities::Mesh { - vertices: vec![ - acadrust::types::Vector3::new(2.0, 2.0, 0.0), - acadrust::types::Vector3::new(3.0, 2.0, 0.0), - acadrust::types::Vector3::new(3.0, 3.0, 0.0), - acadrust::types::Vector3::new(2.0, 3.0, 0.0), - ], - ..Default::default() - }; - let mesh_h = host - .document_mut() - .add_entity(EntityType::Mesh(mesh)) - .unwrap(); - - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetBounds { - id: handle_to_obj(leader_h), - }, - Query::GetBounds { - id: handle_to_obj(mesh_h), - }, - Query::GetEntity { - id: handle_to_obj(leader_h), - }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 10.0).abs() < 1e-6, - "{b:?}" - ); - assert!( - (b.min[1] - 0.0).abs() < 1e-6 && (b.max[1] - 5.0).abs() < 1e-6, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 2.0).abs() < 1e-6 && (b.max[0] - 3.0).abs() < 1e-6, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - match &receipt.query_results[2] { - QueryResult::Entity(e) => assert_eq!(e.kind, "Leader"), - other => panic!("expected entity, got {other:?}"), - } - } - - // ── Phase 5 remaining: typed raster image create ───────────────────────── - - #[test] - fn phase5_create_raster_image_registers_definition() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let img = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateRasterImage( - ocs_doc_api::ops::RasterImageSpec { - file_path: "C:/img/photo.png".into(), - insertion_point: [10.0, 20.0, 0.0], - u_vector: [0.5, 0.0, 0.0], - v_vector: [0.0, 0.5, 0.0], - size: [100.0, 50.0], - }, - )), - ) - .unwrap(), - ); - let handle = obj_to_handle(img); - let Some(EntityType::RasterImage(i)) = host.document().get_entity(handle) else { - panic!("image not found") - }; - assert_eq!(i.file_path, "C:/img/photo.png"); - // The host auto-registered an ImageDefinition (definition_handle set). - assert!( - i.definition_handle.is_some(), - "ImageDefinition was auto-registered" - ); - // Bounds = insertion + u*100 + v*50 = (60, 45) (4-corner bracket). - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: img }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 10.0).abs() < 1e-6 && (b.max[0] - 60.0).abs() < 1e-6, - "{b:?}" - ); - assert!( - (b.min[1] - 20.0).abs() < 1e-6 && (b.max[1] - 45.0).abs() < 1e-6, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - } - - // ── Phase 4 remaining: set_view + viewport-view query ──────────────────── - - #[test] - fn phase4_set_view_and_view_query() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let vp = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateViewport(ocs_doc_api::ops::ViewportSpec { - center: [50.0, 50.0, 0.0], - width: 40.0, - height: 30.0, - view_target: [0.0, 0.0, 0.0], - view_height: 100.0, - })), - ) - .unwrap(), - ); - - // set_view retargets + re-zooms in place. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetViewportView { - id: vp, - view_target: [20.0, 30.0, 0.0], - view_height: 50.0, - }), - ) - .unwrap(); - let Some(EntityType::Viewport(v)) = host.document().get_entity(obj_to_handle(vp)) else { - panic!("viewport not found") - }; - assert!((v.view_target.x - 20.0).abs() < 1e-6 && (v.view_height - 50.0).abs() < 1e-6); - - // viewport_view query reads it back. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetViewportView { id: vp }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::ViewportView { target, height } => { - assert!((target[0] - 20.0).abs() < 1e-6 && (*height - 50.0).abs() < 1e-6); - } - other => panic!("expected viewport view, got {other:?}"), - } - - // set_view on a non-viewport is Unsupported. - let line = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, - start: [0.0; 3], - end: [1.0; 3], - })), - ) - .unwrap(), - ); - assert!(dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetViewportView { - id: line, - view_target: [0.0; 3], - view_height: 1.0 - }) - ) - .is_err()); - } - - // ── Phase 3 remaining: attributes + nested block traversal ────────────── - - #[test] - fn phase3_attributes_and_block_traversal() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // A block "Door" containing a line; insert it, then set/read attributes. - host.document_mut() - .block_records - .add(acadrust::tables::BlockRecord::new("Door")) - .unwrap(); - let line = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, - start: [0.0; 3], - end: [10.0; 3], - })), - ) - .unwrap(), - ); - host.document_mut() - .block_records - .get_mut("Door") - .unwrap() - .entity_handles - .push(obj_to_handle(line)); - - let ins = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateInsert(ocs_doc_api::ops::InsertSpec { - block_name: "Door".into(), - insert_point: [0.0; 3], - scale: 1.0, - rotation: 0.0, - })), - ) - .unwrap(), - ); - - // set_attribute adds a new attribute; get_attributes reads it back. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetAttribute { - id: ins, - tag: "HANDLE".into(), - value: "A-1".into(), - }), - ) - .unwrap(); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetAttributes { id: ins }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Attributes(attrs) => { - assert_eq!(attrs.len(), 1); - assert_eq!(attrs[0], ("HANDLE".to_string(), "A-1".to_string())); - } - other => panic!("expected attributes, got {other:?}"), - } - // set_attribute on an existing tag updates it. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetAttribute { - id: ins, - tag: "HANDLE".into(), - value: "A-2".into(), - }), - ) - .unwrap(); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetAttributes { id: ins }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Attributes(attrs) => assert_eq!(attrs[0].1, "A-2"), - other => panic!("expected attributes, got {other:?}"), - } - - // Nested traversal: GetBlockEntities("Door") returns the line. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBlockEntities { - block_name: "Door".into(), - }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::BlockEntities(v) => { - assert_eq!(v.len(), 1); - assert_eq!(v[0].kind, "Line"); - } - other => panic!("expected block entities, got {other:?}"), - } - // Unknown block -> validation error. - assert!(dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBlockEntities { - block_name: "Nope".into() - }]) - ) - .is_err()); - } - - // ── Phase 2b-c: dimension ───────────────────────────────────────────────── - - #[test] - fn phase2c_dimension_linear_create_measurement_bounds() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Linear dimension from (0,0,0) to (30,0,0) with the line at (0,5,0). - let dim = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateDimensionLinear( - ocs_doc_api::ops::DimensionSpec { - first_point: [0.0, 0.0, 0.0], - second_point: [30.0, 0.0, 0.0], - definition_point: [0.0, 5.0, 0.0], - }, - )), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetDimensionMeasurement { id: dim }, - Query::GetEntity { id: dim }, - Query::GetBounds { id: dim }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::DimensionMeasurement(v) => { - assert!((*v - 30.0).abs() < 1e-6, "measurement {v}") - } - other => panic!("expected measurement, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Entity(e) => assert_eq!(e.kind, "Dimension"), - other => panic!("expected entity, got {other:?}"), - } - match &receipt.query_results[2] { - QueryResult::Bounds(b) => assert!( - (b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 30.0).abs() < 1e-6, - "{b:?}" - ), - other => panic!("expected bounds, got {other:?}"), - } - } - - // ── Phase 2b-b: hatch ──────────────────────────────────────────────────── - - #[test] - fn phase2b_hatch_create_boundary_bounds_delete() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Solid hatch over a unit square boundary. - let sq = vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; - let hatch = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateHatch(ocs_doc_api::ops::HatchSpec { - boundary: sq.clone(), - solid: true, - })), - ) - .unwrap(), - ); - - // Boundary round-trips (one loop, 4 vertices). - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetHatchBoundary { id: hatch }, - Query::GetBounds { id: hatch }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::HatchBoundary(loops) => { - assert_eq!(loops.len(), 1); - assert_eq!(loops[0].len(), 4); - assert!( - (loops[0][0][0] - 0.0).abs() < 1e-9 && (loops[0][2][0] - 10.0).abs() < 1e-9 - ); - } - other => panic!("expected boundary, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Bounds(b) => assert!( - (b.min[0] - 0.0).abs() < 1e-6 && (b.max[1] - 10.0).abs() < 1e-6, - "{b:?}" - ), - other => panic!("expected bounds, got {other:?}"), - } - - // Boundary with < 3 points is a validation error. - assert!(dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateHatch(ocs_doc_api::ops::HatchSpec { - boundary: vec![[0.0, 0.0], [1.0, 1.0]], - solid: true - })) - ) - .is_err()); - - // Generic delete works. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Delete { id: hatch }), - ) - .unwrap(); - assert!(host.document().get_entity(obj_to_handle(hatch)).is_none()); - } - - // ── Phase 5: media & misc (read-mostly) ────────────────────────────────── - - #[test] - fn phase5_media_entities_read_kind_and_bounds() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Insert a RasterImage directly (read-mostly: DocApi reads kind + bounds). - let img = { - use acadrust::entities::RasterImage; - use acadrust::types::{Vector2, Vector3}; - let img = RasterImage { - insertion_point: Vector3::new(10.0, 20.0, 0.0), - u_vector: Vector3::new(0.5, 0.0, 0.0), // 0.5 world-units/pixel in X - v_vector: Vector3::new(0.0, 0.5, 0.0), - size: Vector2::new(100.0, 50.0), - ..Default::default() - }; - host.document_mut() - .add_entity(EntityType::RasterImage(img)) - .unwrap() - }; - let img_id = handle_to_obj(img); - // GetEntity reports kind "RasterImage"; bounds = insertion + u*100 + v*50 = (60, 45). - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetEntity { id: img_id }, - Query::GetBounds { id: img_id }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Entity(e) => assert_eq!(e.kind, "RasterImage"), - other => panic!("expected entity, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 10.0).abs() < 1e-6 && (b.max[0] - 60.0).abs() < 1e-6, - "{b:?}" - ); - assert!( - (b.min[1] - 20.0).abs() < 1e-6 && (b.max[1] - 45.0).abs() < 1e-6, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - // Generic delete works on media entities (read-mostly, but deletable). - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Delete { id: img_id }), - ) - .unwrap(); - assert!(host.document().get_entity(img).is_none()); - } - - // ── Phase 3: containers (block references) ─────────────────────────────── - - #[test] - fn phase3_create_insert_and_transform() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Register a block record so the insert can reference it. - host.document_mut() - .block_records - .add(acadrust::tables::BlockRecord::new("MyBlock")) - .unwrap(); - - // Insert the block at (10,20,0) scale 2, rotation 0. - let ins = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateInsert(ocs_doc_api::ops::InsertSpec { - block_name: "MyBlock".into(), - insert_point: [10.0, 20.0, 0.0], - scale: 2.0, - rotation: 0.0, - })), - ) - .unwrap(), - ); - let handle = obj_to_handle(ins); - let Some(EntityType::Insert(i)) = host.document().get_entity(handle) else { - panic!("insert not found") - }; - assert_eq!(i.block_name, "MyBlock"); - assert!((i.insert_point.x - 10.0).abs() < 1e-9 && (i.x_scale() - 2.0).abs() < 1e-9); - - // Transform the insert by +5 in X. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: ins, - placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]), - }), - ) - .unwrap(); - let Some(EntityType::Insert(i)) = host.document().get_entity(handle) else { - panic!("insert not found") - }; - assert!((i.insert_point.x - 15.0).abs() < 1e-6); - - // Inserting a non-existent block is a Validation error, no entity created. - let before = host.scene().geometry_epoch; - let err = dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateInsert(ocs_doc_api::ops::InsertSpec { - block_name: "NoSuchBlock".into(), - insert_point: [0.0; 3], - scale: 1.0, - rotation: 0.0, - })), - ) - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); - assert_eq!( - host.scene().geometry_epoch, - before, - "no entity on unknown block" - ); - } - - #[test] - fn doc_api_tab_mismatch_is_rejected() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // A request naming a DIFFERENT tab than the bound one is rejected. - let env = DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, - position: [0.0; 3], - })); - let bytes = bincode::serialize(&env).unwrap(); - let wrong_tab = host.tab_id().wrapping_add(999); - let err = execute_doc_api(&mut host, wrong_tab, &bytes).unwrap_err(); - assert!(err.contains("tab mismatch"), "{err}"); - } - - #[test] - fn doc_api_unknown_id_surfaces_structured_error() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let ghost = ObjectId::from_u64(0xDEAD); - let err = dispatch( - &mut host, - DocApiEnvelope::op(Operation::Delete { id: ghost }), - ) - .unwrap_err(); - assert!( - matches!(err, ApiError::Validation { .. } | ApiError::UnknownId(_)), - "{err:?}" - ); - } - - // ── Roundtrip tests: create -> read back via queries -> assert geometry ── - - use ocs_doc_api::ops::{Curve2Spec, PlacementSpec}; - - /// Create an entity via DocApi, then read it back via GetEntity/GetBounds and - /// assert the geometry round-trips with the expected kind + bounds. - #[test] - fn roundtrip_2d_entities_line_circle_point_polyline() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - - // Line [0,0,0]-[10,0,0]: kind + bounds round-trip. - let line = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, - start: [0.0, 0.0, 0.0], - end: [10.0, 0.0, 0.0], - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetEntity { id: line }, - Query::GetBounds { id: line }, - ]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Entity(v) => assert_eq!(v.kind, "Line"), - other => panic!("expected entity, got {other:?}"), - } - match &receipt.query_results[1] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 0.0).abs() < 1e-9 && (b.max[0] - 10.0).abs() < 1e-9, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - - // Circle centre (5,5,0) r=3: bounds = centre ± radius in XY. - let circle = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, - centre: [5.0, 5.0, 0.0], - radius: 3.0, - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: circle }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 2.0).abs() < 1e-9 && (b.max[0] - 8.0).abs() < 1e-9, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - - // Point at (1,2,3): degenerate bounds. - let point = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, - position: [1.0, 2.0, 3.0], - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: point }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(b) => assert_eq!(b.min, [1.0, 2.0, 3.0]), - other => panic!("expected bounds, got {other:?}"), - } - - // Closed polyline (unit square in XY): bounds = [0,1]^2 at z=0. - let poly = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { layer: None, - points: vec![ - [0.0, 0.0, 0.0], - [1.0, 0.0, 0.0], - [1.0, 1.0, 0.0], - [0.0, 1.0, 0.0], - ], - closed: true, - })), - ) - .unwrap(), - ); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: poly }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(b) => { - assert!( - (b.min[0] - 0.0).abs() < 1e-9 && (b.max[1] - 1.0).abs() < 1e-9, - "{b:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - } - - /// Geometric methods round-trip: transform moves a solid (bounds shift); - /// union/subtract produce the expected mass/volume relationships. - #[test] - fn roundtrip_geometric_methods_transform_and_booleans() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - - // Two overlapping boxes [0,10]^3 and [5,15]^3. - let a = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [10.0; 3], - })), - ) - .unwrap(), - ); - let b = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [5.0; 3], - size: [10.0; 3], - })), - ) - .unwrap(), - ); - - // Intersection FIRST: [0,10]^3 ∩ [5,15]^3 = [5,10]^3 = 125. - let intersect_op = Operation::SolidBoolean { - op: BoolOp::Intersection, - a, - b, - erase_sources: true, - }; - let inter = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect_op)).unwrap()); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetVolume { id: inter }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Volume(v) => assert!((*v - 125.0).abs() < 1.0, "intersection vol {v}"), - other => panic!("expected volume, got {other:?}"), - } - - // THEN transform the result by +100 in X (a real, non-identity move): its - // bounds must shift by exactly +100 in X while Y/Z stay at [5,10]. - let move_op = Operation::Transform { - id: inter, - placement: PlacementSpec::at([100.0, 0.0, 0.0]), - }; - dispatch(&mut host, DocApiEnvelope::op(move_op)).unwrap(); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetBounds { id: inter }]), - ) - .unwrap(); - match &receipt.query_results[0] { - QueryResult::Bounds(bb) => { - assert!( - (bb.min[0] - 105.0).abs() < 1e-4 && (bb.max[0] - 110.0).abs() < 1e-4, - "{bb:?}" - ); - assert!( - (bb.min[1] - 5.0).abs() < 1e-4 && (bb.max[1] - 10.0).abs() < 1e-4, - "{bb:?}" - ); - } - other => panic!("expected bounds, got {other:?}"), - } - } - - /// DWG roundtrip: create two intersected solids, write the document to a DWG - /// file, read it back, and assert the intersected solid survives with valid - /// ACIS data (the exact path `restore_solid_models` re-lifts on load). - #[test] - fn roundtrip_intersected_solids_through_dwg_file() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - - // Two overlapping boxes -> intersect -> the result solid (125 vol). - let a = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [10.0; 3], - })), - ) - .unwrap(), - ); - let b = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [5.0; 3], - size: [10.0; 3], - })), - ) - .unwrap(), - ); - let intersect_op = Operation::SolidBoolean { - op: BoolOp::Intersection, - a, - b, - erase_sources: true, - }; - let lens = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect_op)).unwrap()); - - // Write the live document to a STABLE, inspectable DWG in the workspace - // target dir so it can be opened in a CAD viewer after the test. The file - // is kept (not deleted). If the stable path is momentarily locked (e.g. a - // concurrent test run or a viewer holding it), fall back to a process- - // unique path for the roundtrip so the test never flakes. - let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target"); - std::fs::create_dir_all(&dir).expect("create target dir"); - let doc = host.document().clone(); - let stable = dir.join("doc_api_roundtrip_intersected.dwg"); - // Release any stale lock/handle on the stable file before rewriting. - let _ = std::fs::remove_file(&stable); - let path = match acadrust::io::dwg::DwgWriter::write_to_file(&stable, &doc) { - Ok(()) => stable, - Err(_) => { - // Locked by another live process (e.g. a viewer) — write a unique - // file for the roundtrip instead of failing. - let alt = dir.join(format!( - "doc_api_roundtrip_intersected_{}.dwg", - std::process::id() - )); - acadrust::io::dwg::DwgWriter::write_to_file(&alt, &doc).expect("write DWG failed"); - alt - } - }; - assert!(path.exists(), "DWG was written"); - - // Read it back. - let mut reader = acadrust::io::dwg::DwgReader::from_file(&path).expect("open DWG"); - let reloaded = reader.read().expect("read DWG failed"); - - // The intersected solid must be present as a Solid3D with valid ACIS data, - // and re-lifting it must reproduce a body with the expected volume. - let handle = obj_to_handle(lens); - let Some(EntityType::Solid3D(solid)) = reloaded.get_entity(handle) else { - panic!("intersected solid {handle:?} not found as Solid3D in reloaded DWG"); - }; - assert!(solid.has_acis_data(), "reloaded solid carries ACIS data"); - - // Re-lift through the same SAT->kernel path used on load and check volume. - let body = crate::scene::convert::solid3d_tess::kernel_body(solid) - .expect("re-lift reloaded solid failed"); - let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3); - let vol = ocs_doc_api::geom::mesh_volume_centroid(&mesh).0; - assert!( - (vol - 125.0).abs() < 1.0, - "reloaded intersection volume {vol}" - ); - - // The DWG is intentionally kept for inspection (see `path` above). - } - #[test] - fn doc_api_failed_inputs_leave_document_and_history_unchanged() { - use ocs_doc_api::EntitySpec; - let mut app = OpenCADStudio::new_for_test(); - let count = app.tabs[0].scene.document.entity_count(); - let dirty = app.tabs[0].dirty; - let undo = app.tabs[0].history.undo_stack.len(); - let epoch = app.tabs[0].scene.geometry_epoch; - { - let mut host = HostSession::new(&mut app, 0); - let invalid_ops = vec![ - Operation::CreateMany(vec![ - EntitySpec::Curve(Curve2Spec::Point { layer: None, position: [0.0; 3] }), - EntitySpec::Solid(SolidPrimitive::Sphere { - centre: [0.0; 3], - radius: f64::NAN, - }), - ]), - Operation::CreateCurve(Curve2Spec::Circle { layer: None, - centre: [0.0; 3], - radius: -1.0, - }), - Operation::CreateCurve(Curve2Spec::Polyline { layer: None, - points: vec![[0.0; 3], [1.0; 3]], - closed: false, - }), - Operation::CreateText(ocs_doc_api::ops::TextSpec { - value: "test".into(), - insertion_point: [0.0; 3], - height: f64::INFINITY, - rotation: 0.0, - }), - ]; - for op in invalid_ops { - assert!(dispatch(&mut host, DocApiEnvelope::op(op)).is_err()); - assert!(!host.scene().is_recording_undo()); - } - } - assert_eq!(app.tabs[0].scene.document.entity_count(), count); - assert_eq!(app.tabs[0].scene.geometry_epoch, epoch); - assert_eq!(app.tabs[0].history.undo_stack.len(), undo); - assert_eq!(app.tabs[0].dirty, dirty); - } - - #[test] - fn doc_api_bulk_creation_is_dirty_and_undoes_in_one_step() { - use ocs_doc_api::EntitySpec; - let mut app = OpenCADStudio::new_for_test(); - let undo = app.tabs[0].history.undo_stack.len(); - let count = app.tabs[0].scene.document.entity_count(); - let ids = { - let mut host = HostSession::new(&mut app, 0); - let receipt = dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateMany(vec![ - EntitySpec::Curve(Curve2Spec::Point { layer: None, position: [1.0; 3] }), - EntitySpec::Solid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [2.0; 3], - }), - ])), - ) - .unwrap(); - receipt.outcome.unwrap().new_ids().to_vec() - }; - assert!(app.tabs[0].dirty); - assert_eq!(app.tabs[0].history.undo_stack.len(), undo + 1); - assert_eq!(app.tabs[0].scene.document.entity_count(), count + 2); - app.undo_active_tab(); - assert_eq!(app.tabs[0].scene.document.entity_count(), count); - for id in &ids { - assert!(!app.tabs[0] - .scene - .solid_models - .contains_key(&obj_to_handle(*id))); - } - app.redo_active_tab(); - for id in ids { - assert!(app.tabs[0] - .scene - .document - .get_entity(obj_to_handle(id)) - .is_some()); - } - } - - #[test] - fn doc_api_text_scale_and_elevated_extrusion() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let text = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateText(ocs_doc_api::ops::TextSpec { - value: "label".into(), - insertion_point: [1.0, 2.0, 0.0], - height: 2.0, - rotation: 0.0, - })), - ) - .unwrap(), - ); - let placement = PlacementSpec { - origin: [0.0, 0.0, 7.0], - x_axis: [2.0, 0.0, 0.0], - y_axis: [0.0, 2.0, 0.0], - z_axis: [0.0, 0.0, 2.0], - }; - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: text, - placement, - }), - ) - .unwrap(); - let EntityType::Text(text) = host.document().get_entity(obj_to_handle(text)).unwrap() - else { - panic!("text") - }; - assert!((text.height - 4.0).abs() < 1e-9); - assert!((text.insertion_point.z - 7.0).abs() < 1e-9); - let profile = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { layer: None, - points: vec![ - [0.0, 0.0, 7.0], - [2.0, 0.0, 7.0], - [2.0, 3.0, 7.0], - [0.0, 3.0, 7.0], - ], - closed: true, - })), - ) - .unwrap(), - ); - let solid = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::Extrude { - profile, - direction: [0.0, 0.0, 5.0], - }), - ) - .unwrap(), - ); - let bounds = host.bounds(solid).unwrap(); - assert!( - (bounds.min[2] - 7.0).abs() < 1e-6 && (bounds.max[2] - 12.0).abs() < 1e-6, - "{bounds:?}" - ); - assert!((host.volume(solid).unwrap() - 30.0).abs() < 1e-6); - } - - #[test] - fn doc_api_envelope_guards_and_query_budget_reset() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let id = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [2.0; 3], - })), - ) - .unwrap(), - ); - let mut env = DocApiEnvelope::queries(vec![Query::GetVolume { id }]); - env.version += 1; - assert!(matches!( - dispatch(&mut host, env), - Err(ApiError::Unsupported(_)) - )); - let valid = DocApiEnvelope::queries(vec![Query::GetVolume { id }]); - let mut bytes = bincode::serialize(&valid).unwrap(); - bytes.push(0); - let tab = host.tab_id(); - assert!(execute_doc_api(&mut host, tab, &bytes).is_err()); - let bytes = bincode::serialize(&valid).unwrap(); - assert!(execute_doc_api(&mut host, tab + 1, &bytes).is_err()); - host.scene_mut().doc_api_cold_tess_used = 32; - let receipt = dispatch(&mut host, valid).unwrap(); - assert!( - matches!(receipt.query_results[0], QueryResult::Volume(v) if (v - 8.0).abs() < 1e-9) - ); - assert_eq!(host.scene().doc_api_cold_tess_used, 1); - } - #[test] - fn doc_api_plugin_wire_dispatch_and_image_undo() { - use ocs_plugin_api::ipc::{ - protocol::{PluginRequest, PluginResponse}, - server::handle_plugin_request, - }; - let mut app = OpenCADStudio::new_for_test(); - let objects = app.tabs[0].scene.document.objects.len(); - let id = { - let mut host = HostSession::new(&mut app, 0); - let env = DocApiEnvelope::op(Operation::CreateRasterImage( - ocs_doc_api::ops::RasterImageSpec { - file_path: "image.png".into(), - insertion_point: [0.0; 3], - u_vector: [1.0, 0.0, 0.0], - v_vector: [0.0, 1.0, 0.0], - size: [2.0; 2], - }, - )); - let request = PluginRequest::DocApiRequest { - tab_id: host.tab_id(), - bytes: bincode::serialize(&env).unwrap(), - }; - let request = bincode::deserialize(&bincode::serialize(&request).unwrap()).unwrap(); - let response = handle_plugin_request(&mut host, request, &mut |_| {}); - let response: PluginResponse = - bincode::deserialize(&bincode::serialize(&response).unwrap()).unwrap(); - let PluginResponse::DocApiResponse { bytes } = response else { - panic!("unexpected response") - }; - let result: ApiResult = bincode::deserialize(&bytes).unwrap(); - new_id(&result.unwrap()) - }; - assert!(app.tabs[0].scene.document.objects.len() > objects); - app.undo_active_tab(); - assert!(app.tabs[0] - .scene - .document - .get_entity(obj_to_handle(id)) - .is_none()); - assert_eq!(app.tabs[0].scene.document.objects.len(), objects); - app.redo_active_tab(); - assert!(app.tabs[0] - .scene - .document - .get_entity(obj_to_handle(id)) - .is_some()); - assert!(app.tabs[0].scene.document.objects.len() > objects); - } - - #[test] - fn doc_api_layer_crud_roundtrip_and_entity_assignment() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - - // Create a line to assign to a layer later. - let line = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Line { layer: None, - start: [0.0; 3], - end: [1.0; 3], - })), - ) - .unwrap(), - ); - - // List default layers — must include "0". - let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::ListLayers])).unwrap(); - let QueryResult::Layers(list) = &receipt.query_results[0] else { - panic!("expected Layers result"); - }; - assert!(list.iter().any(|l| l.name == "0")); - - // Create a new layer. - let mut info = LayerInfo::new("Walls"); - info.color = Color::Index(1); - info.line_weight = LineWeight::Value(25); - info.flags = LayerFlags { - frozen: false, - locked: false, - frozen_in_new_viewport: false, - off: false, - }; - dispatch(&mut host, DocApiEnvelope::op(Operation::CreateLayer(info.clone()))).unwrap(); - - // Update the layer color. - let mut updated = info.clone(); - updated.color = Color::Index(2); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::UpdateLayer { - name: " Walls ".into(), - info: updated.clone(), - }), - ) - .unwrap(); - - // Assign the line to the new layer. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetEntityLayer { - id: line, - layer: "Walls".into(), - }), - ) - .unwrap(); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetEntityLayer { id: line }]), - ) - .unwrap(); - assert!(matches!(&receipt.query_results[0], QueryResult::EntityLayer(name) if name == "Walls")); - - // Deleting a layer still in use fails. - let err = dispatch( - &mut host, - DocApiEnvelope::op(Operation::DeleteLayer { - name: "Walls".into(), - }), - ) - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); - - // Move the line back to "0", then delete the layer. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetEntityLayer { - id: line, - layer: "0".into(), - }), - ) - .unwrap(); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::DeleteLayer { - name: " Walls ".into(), - }), - ) - .unwrap(); - let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::ListLayers])).unwrap(); - let QueryResult::Layers(list) = &receipt.query_results[0] else { - panic!("expected Layers result"); - }; - assert!(!list.iter().any(|l| l.name == "Walls")); - } - - #[test] - fn doc_api_xdata_batch_rejects_a_locked_entity_before_mutating() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let line = |host: &mut HostSession<'_>| { - new_id(&dispatch( - host, - DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Line { - start: [0.0; 3], - end: [1.0; 3], - layer: None, - })), - ).unwrap()) - }; - let first = line(&mut host); - let locked = line(&mut host); - - let mut layer = LayerInfo::new("Locked"); - dispatch(&mut host, DocApiEnvelope::op(Operation::CreateLayer(layer.clone()))).unwrap(); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetEntityLayer { - id: locked, - layer: layer.name.clone(), - }), - ) - .unwrap(); - layer.flags.locked = true; - dispatch( - &mut host, - DocApiEnvelope::op(Operation::UpdateLayer { - name: layer.name.clone(), - info: layer, - }), - ) - .unwrap(); - - let record = |application_name: &str| XDataRecord { - application_name: application_name.into(), - values: vec![XDataValue::Integer32(42)], - }; - let result = dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetXDataMany(vec![ - (first, "FIRST".into(), Some(record("FIRST"))), - (locked, "LOCKED".into(), Some(record("LOCKED"))), - ])), - ); - assert!(matches!(result, Err(ApiError::Validation { .. }))); - assert!(host.xdata(first, "FIRST").unwrap().is_none()); - } - - #[test] - fn doc_api_xrecords_keep_one_named_owner() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let spec = |name: &str| XRecordSpec { - name: name.into(), - cloning_flags: XRecordCloningFlags::NotApplicable, - entries: vec![XRecordEntry { - code: 1, - value: XRecordValue::String("value".into()), - }], - }; - - let first = new_id(&dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateXRecord(spec("FIRST"))), - ).unwrap()); - let first_handle = obj_to_handle(first); - let ObjectType::XRecord(first_record) = host.document().objects.get(&first_handle).unwrap() - else { - panic!("expected XRecord") - }; - let owner = first_record.owner; - assert!(matches!(host.document().objects.get(&owner), Some(ObjectType::Dictionary(dict)) if - dict.entries.iter().any(|(name, child)| name == "FIRST" && *child == first_handle))); - - let object_count = host.document().objects.len(); - assert!(matches!( - dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateXRecord(spec("first"))), - ), - Err(ApiError::Validation { .. }) - )); - assert_eq!(host.document().objects.len(), object_count); - - let second = new_id(&dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateXRecord(spec("SECOND"))), - ).unwrap()); - assert!(matches!( - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetXRecord { - id: second, - spec: spec("FIRST"), - }), - ), - Err(ApiError::Validation { .. }) - )); - assert_eq!(host.xrecord(second).unwrap().unwrap().name, "SECOND"); - - let mut invalid = spec("INVALID"); - invalid.entries[0] = XRecordEntry { - code: 1, - value: XRecordValue::Int32(7), - }; - assert!(matches!( - dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateXRecord(invalid)), - ), - Err(ApiError::Validation { .. }) - )); - } - - #[test] - fn doc_api_enumerate_entities_and_geometry_queries() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - - // Create entities on different layers. - let line_id = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Line { layer: None, - start: [1.0, 2.0, 3.0], - end: [4.0, 5.0, 6.0], - })), - ) - .unwrap(), - ); - let point_id = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Point { layer: None, - position: [7.0, 8.0, 9.0], - })), - ) - .unwrap(), - ); - - // Create a layer and move the point to it. - let mut info = LayerInfo::new("Markers"); - info.color = Color::Index(3); - dispatch(&mut host, DocApiEnvelope::op(Operation::CreateLayer(info))).unwrap(); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetEntityLayer { - id: point_id, - layer: "Markers".into(), - }), - ) - .unwrap(); - - // Enumerate all entities. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::EnumerateEntities { - kind: None, - layer: None, - include_bounds: true, - }]), - ) - .unwrap(); - let QueryResult::Entities(all) = &receipt.query_results[0] else { - panic!("expected Entities result"); - }; - assert!(all.iter().any(|e| e.id == line_id && e.kind == "Line" && e.layer == "0")); - assert!(all.iter().any(|e| e.id == point_id && e.kind == "Point" && e.layer == "Markers")); - - // Filter by kind. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::EnumerateEntities { - kind: Some("Line".into()), - layer: None, - include_bounds: true, - }]), - ) - .unwrap(); - let QueryResult::Entities(lines) = &receipt.query_results[0] else { - panic!("expected Entities result"); - }; - assert_eq!(lines.len(), 1); - assert_eq!(lines[0].id, line_id); - - // Filter by layer. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::EnumerateEntities { - kind: None, - layer: Some(" Markers ".into()), - include_bounds: true, - }]), - ) - .unwrap(); - let QueryResult::Entities(markers) = &receipt.query_results[0] else { - panic!("expected Entities result"); - }; - assert_eq!(markers.len(), 1); - assert_eq!(markers[0].id, point_id); - - // Geometry queries. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetPointPosition { id: point_id }, - Query::GetLineGeometry { id: line_id }, - ]), - ) - .unwrap(); - let QueryResult::PointPosition(pos) = &receipt.query_results[0] else { - panic!("expected PointPosition result"); - }; - assert!((pos[0] - 7.0).abs() < 1e-9); - assert!((pos[1] - 8.0).abs() < 1e-9); - assert!((pos[2] - 9.0).abs() < 1e-9); - let QueryResult::LineGeometry((start, end)) = &receipt.query_results[1] else { - panic!("expected LineGeometry result"); - }; - assert!((start[0] - 1.0).abs() < 1e-9); - assert!((end[0] - 4.0).abs() < 1e-9); - - // Wrong entity family is rejected. - let err = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetPointPosition { id: line_id }]), - ) - .unwrap_err(); - assert!(matches!(err, ApiError::Unsupported { .. })); - } -} diff --git a/src/app/document.rs b/src/app/document.rs index 3c866fac2..6bdcd7329 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -240,6 +240,17 @@ impl DocumentTab { .and_then(|index| self.block_edits.get_mut(index)) } + /// The [`crate::scene::sketch_constraints::SketchScope`] 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 { + match self.active_block_edit_session() { + Some(session) => crate::scene::sketch_constraints::SketchScope::Block(session.br_handle), + None => crate::scene::sketch_constraints::SketchScope::ModelSpace, + } + } + /// The active WCS↔UCS converter for this tab — identity when no UCS is set. /// Every consumer that needs UCS-relative coordinates goes through this. pub(super) fn ucs_xform(&self) -> super::helpers::UcsXform { @@ -644,7 +655,11 @@ impl DocumentTab { /// full entity store. #[derive(Clone)] pub(super) enum HistorySnapshot { - Delta(DeltaSnapshot), + // Boxed: `DeltaSnapshot` grew past the other variants once + // `sketch_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), ObjectVisibility(ObjectVisibilitySnapshot), } @@ -677,6 +692,18 @@ impl HistorySnapshot { .as_ref() .map_or(0, |(before, after)| before.len().saturating_add(after.len())), ) + .saturating_add( + d.sketch_constraints + .iter() + .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.label.len()), HistorySnapshot::ObjectVisibility(v) => v .before @@ -733,6 +760,17 @@ 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 + /// entities (e.g. ERASE removing a constraint set's touched constraints) + /// — `sketch_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, + /// Drawing-wide parameter table changed by this transaction. + pub(super) named_parameters: Option<( + crate::scene::named_parameters::ParameterTable, + crate::scene::named_parameters::ParameterTable, + )>, pub(super) label: String, } @@ -793,6 +831,15 @@ pub(super) struct ObjectEntryDelta { pub(super) after: Option, } +/// One sketch-constraint scope's before/after image within an entity delta. +/// This keeps constraint cleanup atomic with edits such as entity erasure. +#[derive(Clone)] +pub(super) struct SketchConstraintsEntryDelta { + pub(super) scope: crate::scene::sketch_constraints::SketchScope, + pub(super) before: crate::scene::sketch_constraints::SketchConstraintSet, + pub(super) after: crate::scene::sketch_constraints::SketchConstraintSet, +} + #[derive(Default)] pub(super) struct HistoryState { pub(super) undo_stack: Vec, diff --git a/src/app/history.rs b/src/app/history.rs index aef5c712d..a43d387e0 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -1,7 +1,7 @@ use super::{ document::{ DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot, - PendingHistorySnapshot, StructureSnapshot, TableEntryDelta, + PendingHistorySnapshot, SketchConstraintsEntryDelta, StructureSnapshot, TableEntryDelta, }, OpenCADStudio, }; @@ -225,9 +225,11 @@ impl OpenCADStudio { dirty_after: true, active_layer: None, structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)), + sketch_constraints: Vec::new(), + named_parameters: None, label: label.into(), }; - self.push_undo_entry(i, HistorySnapshot::Delta(delta)); + self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); } pub(super) fn defer_live_entity_history_after(&mut self, i: usize, handle: Handle) { @@ -334,9 +336,11 @@ impl OpenCADStudio { 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(), + named_parameters: None, label: pending.label, }; - self.push_undo_entry(i, HistorySnapshot::Delta(delta)); + self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); } pub(super) fn finish_all_pending_history(&mut self) { @@ -441,9 +445,11 @@ impl OpenCADStudio { dirty_after: self.tabs[i].dirty, active_layer: None, structure: Some(StructureSnapshot::Layers(entries)), + sketch_constraints: Vec::new(), + named_parameters: None, label: pending.label, }; - self.push_undo_entry(i, HistorySnapshot::Delta(delta)); + self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); } pub(super) fn begin_text_style_undo( @@ -496,9 +502,11 @@ impl OpenCADStudio { dirty_after: self.tabs[i].dirty, active_layer: None, structure: Some(StructureSnapshot::TextStyles(entries)), + sketch_constraints: Vec::new(), + named_parameters: None, label: pending.label, }; - self.push_undo_entry(i, HistorySnapshot::Delta(delta)); + self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); } pub(super) fn begin_dim_style_undo( @@ -551,9 +559,11 @@ impl OpenCADStudio { dirty_after: self.tabs[i].dirty, active_layer: None, structure: Some(StructureSnapshot::DimStyles(entries)), + sketch_constraints: Vec::new(), + named_parameters: None, label: pending.label, }; - self.push_undo_entry(i, HistorySnapshot::Delta(delta)); + self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); } fn group_object_state(&self, i: usize) -> FxHashMap { @@ -614,9 +624,11 @@ impl OpenCADStudio { dirty_after: self.tabs[i].dirty, active_layer: None, structure: Some(StructureSnapshot::Objects(entries)), + sketch_constraints: Vec::new(), + named_parameters: None, label: pending.label, }; - self.push_undo_entry(i, HistorySnapshot::Delta(delta)); + self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); } pub(super) fn commit_style_undo( @@ -647,9 +659,11 @@ impl OpenCADStudio { dim_names, object_handles, }), + sketch_constraints: Vec::new(), + named_parameters: None, label: "STYLE".to_string(), }; - self.push_undo_entry(i, HistorySnapshot::Delta(delta)); + self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); } /// Dimension copies still need a full snapshot because they clone a fresh @@ -727,7 +741,8 @@ impl OpenCADStudio { pending.label ); } - let (entity_before, object_before) = rec.into_recorded_images(); + let (entity_before, object_before, sketch_constraints_before, named_parameters_before) = + rec.into_recorded_images(); let entities: Vec<(Handle, Option>, Option>)> = entity_before .into_iter() .map(|(h, before)| { @@ -746,6 +761,25 @@ 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, + }) + }) + .collect(); + let named_parameters = named_parameters_before.and_then(|before| { + let after = self.tabs[i].scene.named_parameters().clone(); + (before != after).then_some((before, after)) + }); let selected_after = self.tabs[i].scene.selected.iter().copied().collect(); let dirty_after = self.tabs[i].dirty; let mut structure = pending.structure_before.map(StructureSnapshot::Full); @@ -781,9 +815,11 @@ impl OpenCADStudio { dirty_after, active_layer: None, structure, + sketch_constraints, + named_parameters, label: pending.label, }; - self.push_undo_entry(i, HistorySnapshot::Delta(delta)); + self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta))); } /// Apply one side of a delta entry in place: `undo` restores each entity's @@ -909,6 +945,13 @@ 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 { + let value = if undo { &entry.before } else { &entry.after }; + *scene.sketch_constraint_set_mut(entry.scope) = value.clone(); + } + if let Some((before, after)) = &d.named_parameters { + scene.named_parameters = if undo { before } else { after }.clone(); + } let (sel, dirty) = if undo { (&d.selected_before, d.dirty_before) } else { diff --git a/src/app/mod.rs b/src/app/mod.rs index 0c2cd3e73..733feefbd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,8 +9,6 @@ pub(crate) mod config; pub use automation::{export_headless, serve}; mod command_driver; pub(crate) mod commands; -#[cfg(not(target_arch = "wasm32"))] -mod doc_api; mod document; pub(crate) mod expr_eval; mod find_replace; @@ -511,6 +509,7 @@ pub(super) struct OpenCADStudio { /// Selected-object count past which grips stop being generated /// (GRIPOBJLIMIT, 0..=32767; 0 = no limit). grip_object_limit: i32, + ncopy_bind: bool, /// Drawing viewport cursor style (CURSORTYPE). cursor_type: settings::CursorType, /// Explicit crosshair colour; `None` retains automatic contrast. @@ -547,6 +546,14 @@ 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 + /// glyph plus a driven value or named-parameter name. When false, every + /// pill shows only the glyph. + pub show_constraint_values: bool, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); /// 0 disables autosave. pub savetime_min: i32, @@ -1040,6 +1047,15 @@ pub(super) struct OpenCADStudio { /// edited. Seeded from `command_aliases` on open, committed back on close. alias_editor_rows: Vec<(String, String)>, + // ── Named Parameters ────────────────────────────────────────────────── + /// Working buffer for the PARAMETERS modal. Unlike `alias_editor_rows`, + /// this isn't a copy of a separate app-level store — the real state + /// lives per-document at `Scene::named_parameters`; this buffer is + /// seeded from the active tab's table on open and only written back to + /// it on Apply (`apply_named_parameter_editor_rows`, + /// `src/app/named_parameters.rs`). + named_parameter_editor_rows: Vec, + // ── Layout Manager Panel ────────────────────────────────────────────── layout_manager_selected: String, layout_manager_rename_buf: String, @@ -1717,6 +1733,7 @@ pub enum ModalKind { AttributeEditor, LayerDeleteWarning, Aliases, + NamedParameters, ScaleManager, /// Add / remove the annotation scales a single selected object has a /// per-object representation for. @@ -2071,6 +2088,11 @@ 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), /// Switch the interface language and redraw localized views. LanguageChanged(crate::i18n::Language), /// Drop every entity from the active drawing. @@ -2494,6 +2516,10 @@ pub enum Message { CloseLayoutList, /// Cycle the coordinate readout mode ($COORDS): static → live → polar. CycleCoordsMode, + /// Removes one flagged redundant or conflicting constraint from the + /// current sketch scope. + /// No-op if the scope currently has no flagged conflict. + ResolveOneSketchConflict, /// Toggle the status-bar customization menu open/closed. ToggleStatusBarMenu, /// Close the status-bar customization menu. @@ -2789,6 +2815,46 @@ pub enum Message { AliasEditorRemove(usize), /// Commit the edited rows to the alias table (Apply button); stays open. AliasEditorApply, + // ── Named Parameters (PARAMETERS) ─────────────────────────────────── + /// Open the named-parameter editor, seeding rows from the active tab's + /// `Scene::named_parameters`. + NamedParametersOpen, + /// Live edit of the name or formula in row `idx`. + NamedParametersInput { + idx: usize, + field: crate::ui::window::named_parameters::ParamField, + value: String, + }, + /// Append a blank parameter row. + NamedParametersAdd, + /// Remove parameter row `idx`. + NamedParametersRemove(usize), + /// Commit the edited rows to `Scene::named_parameters` (Apply button) + /// and re-solve every constraint that reads a named parameter; stays + /// open. + NamedParametersApply, + // ── Parameters / Constraints sections embedded in Properties ────────── + /// Live text of one column of parameter row `index`, keyed by its + /// `ParameterTable::iter()` position — see `PropValue::ParamRow`. + PropParamInput { + index: usize, + field: crate::ui::window::named_parameters::ParamField, + value: String, + }, + /// Commit row `index`'s buffered edit for `field` to `Scene:: + /// named_parameters` (Enter / losing focus) and re-solve whatever it + /// drives. + PropParamCommit { + index: usize, + field: crate::ui::window::named_parameters::ParamField, + }, + /// Remove parameter row `index` immediately. + PropParamDelete(usize), + /// Append a fresh, uniquely-named parameter to `Scene::named_parameters`. + PropParamAddNew, + /// A Constraints-section row was clicked: select every entity in the + /// list (replacing the current selection) in the viewport. + PropConstraintLinkClick(Vec), // ── About window ──────────────────────────────────────────────────── AboutOpen, /// Close whatever in-canvas modal dialog is open (Plan B). @@ -3461,6 +3527,7 @@ impl OpenCADStudio { double_click_block_refedit: false, double_click_block_attedit: true, grip_object_limit: settings::DEFAULT_GRIP_OBJECT_LIMIT, + ncopy_bind: false, cursor_type: settings::CursorType::Crosshair, crosshair_color: None, crosshair_color_input: String::new(), @@ -3478,6 +3545,8 @@ 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, default_paper_bg_color: None, @@ -3669,6 +3738,7 @@ impl OpenCADStudio { // Command aliases (populated from ocad.pgp just after construction) command_aliases: rustc_hash::FxHashMap::default(), alias_editor_rows: Vec::new(), + named_parameter_editor_rows: Vec::new(), // Layout Manager layout_manager_selected: "Model".to_string(), layer_state_selected: None, diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index c39129315..d60992822 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -16,8 +16,6 @@ pub(crate) struct HostSession<'a> { app: &'a mut OpenCADStudio, tab: usize, doc_store: Option>, - /// In-flight DocApi v2 undo delta (between `push_undo` and `finalize_op`). - doc_api_pending: Option, } impl<'a> HostSession<'a> { @@ -26,7 +24,6 @@ impl<'a> HostSession<'a> { app, tab, doc_store: None, - doc_api_pending: None, } } @@ -245,39 +242,6 @@ impl<'a> HostSession<'a> { self.app.push_undo_snapshot(self.tab, label); } - // ── DocApi v2 accessors (used by `app::doc_api` backend) ─────────────── - - pub(crate) fn scene(&self) -> &crate::scene::Scene { - &self.app.tabs[self.tab].scene - } - - pub(crate) fn scene_mut(&mut self) -> &mut crate::scene::Scene { - &mut self.app.tabs[self.tab].scene - } - - /// DocApi `push_undo`: capture entity and document-structure changes for one op. - pub(crate) fn begin_doc_api_undo(&mut self, label: &str) { - self.doc_api_pending = self.app.begin_undo(self.tab, label, 1, false); - } - - pub(crate) fn cancel_doc_api_undo(&mut self) { - if self.doc_api_pending.take().is_some() { - self.scene_mut().take_undo_recording(); - } - } - - /// DocApi `finalize_op`: close the delta + republish the document view. - /// The underlying entity op (add/update/remove/register_solid_model) already - /// bumped `geometry_epoch`, so we do NOT bump again here — exactly one undo - /// step is recorded, and the epoch has advanced for this op. - pub(crate) fn commit_doc_api_undo(&mut self) { - self.set_dirty(); - if let Some(pending) = self.doc_api_pending.take() { - self.app.commit_undo_delta(self.tab, pending); - } - self.publish_document_view(); - } - pub fn set_dirty(&mut self) { self.app.tabs[self.tab].dirty = true; } @@ -396,10 +360,6 @@ impl HostApi for HostSession<'_> { fn close_document_view_v4(&mut self, tab_id: u64) { self.close_document_view_v4(tab_id) } - #[cfg(not(target_arch = "wasm32"))] - fn doc_api_dispatch(&mut self, tab_id: u64, bytes: &[u8]) -> Result, String> { - super::doc_api::execute_doc_api(self, tab_id, bytes) - } } /// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand) diff --git a/src/app/properties.rs b/src/app/properties.rs index 79bf7c7c8..ac9ae40f4 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -406,6 +406,37 @@ impl OpenCADStudio { read_only(t!("Visual Style").as_ref(), tab.visual_style.clone()), ], }, + // Document-wide named-parameter table embedded here + // instead of a separate modal — see `PropValue:: + // ParamRow`'s doc comment. Belongs on the no- + // selection (drawing-level) page, not a per-entity + // one: a parameter isn't owned by any one entity. + PropSection { + title: t!("Parameters").into_owned(), + props: { + let mut props: Vec = scene + .named_parameters() + .iter() + .enumerate() + .map(|(index, p)| Property { + label: String::new(), + field: "named_parameter", + value: PropValue::ParamRow { + index, + name: p.name.clone(), + formula: p.source.clone(), + resolved: scene.named_parameters().resolve(&p.name).map_err(|e| e.to_string()), + }, + }) + .collect(); + props.push(Property { + label: String::new(), + field: "named_parameter_add", + value: PropValue::ParamAddRow, + }); + props + }, + }, ]; ui::PropertiesPanel { title: t!("No selection").into_owned(), @@ -2126,6 +2157,53 @@ impl OpenCADStudio { if compact_solid { retain_compact_solid_sections(&mut sections); } + // Persistent sketch-constraint list for this entity — + // the Properties-panel analogue of the viewport's + // constraint glyph pills: one row per constraint + // touching `handle`, clickable to select every entity + // it relates (PropConstraintLinkClick). Omitted + // entirely when the entity has none — an unconstrained + // entity (the common case) shouldn't gain empty + // bookkeeping. + { + let scope = self.tabs[i].current_sketch_scope(); + let scene = &self.tabs[i].scene; + if let Some(set) = scene.sketch_constraint_set(scope) { + let props: Vec = set + .constraints_touching(handle) + .map(|c| { + let conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id); + let value_suffix = match &c.driving_param { + Some(crate::scene::named_parameters::DrivingValue::Literal(v)) => { + format!(" = {v:.2}") + } + Some(crate::scene::named_parameters::DrivingValue::Named(name)) => { + match scene.named_parameters().resolve(name) { + Ok(v) => format!(" = {name} ({v:.2})"), + Err(_) => format!(" = {name} (?)"), + } + } + None => String::new(), + }; + let label = format!("{} {:?}{}", c.kind.glyph_symbol(), c.kind, value_suffix); + let mut handles: Vec = c.refs.iter().map(|r| r.entity).collect(); + handles.sort_by_key(|h| h.value()); + handles.dedup(); + crate::scene::model::object::Property { + label, + field: "sketch_constraint", + value: crate::scene::model::object::PropValue::EntityLink { handles, conflicting }, + } + }) + .collect(); + if !props.is_empty() { + sections.push(crate::scene::model::object::PropSection { + title: t!("Constraints").into_owned(), + props, + }); + } + } + } let title = match entity { acadrust::EntityType::Insert(ins) => { let is_xref = self.tabs[i] @@ -3112,6 +3190,15 @@ fn make_sections_read_only( } PropValue::Stepper { display, .. } => display.clone(), PropValue::AttrText { value, .. } => value.clone(), + // The Constraints/Parameters sections aren't per-entity editable + // fields to begin with (a constraint link is a navigation + // action, not a value; a parameter belongs to the whole + // document, not the locked entity) — locking the entity doesn't + // change either, so just carry through readable text instead of + // collapsing them to a value that was never editable anyway. + PropValue::EntityLink { handles, .. } => format!("{} entity link(s)", handles.len()), + PropValue::ParamRow { name, formula, .. } => format!("{name} = {formula}"), + PropValue::ParamAddRow => String::new(), }; property.field = "locked_read_only"; property.value = PropValue::ReadOnly(text); diff --git a/src/app/settings.rs b/src/app/settings.rs index f40d41d54..14f223aa3 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -203,6 +203,8 @@ pub struct UserSettings { /// GRIPOBJLIMIT: past this many selected objects, no grips are drawn at /// all. 0 means no limit. The drawing header carries no slot for it. pub grip_object_limit: i32, + /// Nested-copy symbol handling: false inserts, true binds. + pub ncopy_bind: bool, /// Last Options page; unknown saved names fall back without rejecting the config. #[serde(default, deserialize_with = "deserialize_options_tab")] pub options_tab: crate::ui::window::options::OptionsTab, @@ -276,6 +278,16 @@ 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 + /// canvas detail on a dense sketch. + #[serde(default = "default_show_constraint_values")] + pub show_constraint_values: bool, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command). /// 0 disables autosave. pub savetime_min: i32, @@ -342,6 +354,10 @@ fn default_dimension_continue_mode() -> i16 { 1 } +fn default_show_constraint_values() -> bool { + true +} + fn deserialize_clipromptlines<'de, D>(de: D) -> Result where D: serde::Deserializer<'de>, @@ -372,6 +388,7 @@ impl Default for UserSettings { double_click_block_refedit: false, double_click_block_attedit: true, grip_object_limit: DEFAULT_GRIP_OBJECT_LIMIT, + ncopy_bind: false, cursor_type: CursorType::Crosshair, crosshair_color: None, lineweight_display_scale: 100, @@ -395,6 +412,8 @@ 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(), pick_add: true, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 23df084a7..c455e14e3 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -890,6 +890,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } pub(super) fn on_command_escape(&mut self) -> Task { + if self.ribbon.escape_extension() { + return Task::none(); + } // Esc drops an unconsumed one-shot snap override and closes // its menu (#337). Falls through — Esc keeps its usual effect. self.snap_override_popup = None; diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 518d5c4a5..a0157ecf5 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -404,6 +404,7 @@ impl OpenCADStudio { double_click_block_refedit: self.double_click_block_refedit, double_click_block_attedit: self.double_click_block_attedit, grip_object_limit: self.grip_object_limit, + ncopy_bind: self.ncopy_bind, cursor_type: self.cursor_type, crosshair_color: self.crosshair_color, lineweight_display_scale: self.lineweight_display_scale, @@ -432,6 +433,8 @@ 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(), pick_add: self.pick_add, @@ -467,6 +470,7 @@ impl OpenCADStudio { self.double_click_block_refedit = s.double_click_block_refedit; self.double_click_block_attedit = s.double_click_block_attedit; self.grip_object_limit = s.grip_object_limit.clamp(0, 32767); + self.ncopy_bind = s.ncopy_bind; self.cursor_type = s.cursor_type; self.crosshair_color = s.crosshair_color; self.crosshair_color_input = s @@ -510,6 +514,8 @@ 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 = crate::io::canonical_save_format(&s.default_save_format).to_string(); @@ -1438,6 +1444,11 @@ 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. + self.tabs[i].scene.load_named_parameters_from_document(); self.tabs[i].active_layer = self.tabs[i] .scene .document @@ -1701,6 +1712,13 @@ pub(super) fn on_open_file(&mut self) -> Task { self.tabs[i].scene.document.header.user_real1 = self.tabs[i].scene.annotation_scale as f64; self.sync_solid_models_for_save(i); + // 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); } #[cfg(not(target_arch = "wasm32"))] @@ -2651,6 +2669,9 @@ pub(super) fn on_open_file(&mut self) -> Task { 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, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index f84da07ef..1370c45c6 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3356,6 +3356,10 @@ impl OpenCADStudio { } Task::none() } + Message::ResolveOneSketchConflict => { + self.resolve_one_sketch_conflict(); + Task::none() + } Message::TogglePolar => { self.polar_mode ^= true; if self.polar_mode { @@ -5926,6 +5930,65 @@ impl OpenCADStudio { Task::none() } + // ── Named Parameters (PARAMETERS) ───────────────────────────────── + Message::NamedParametersOpen => { + let i = self.active_tab; + self.named_parameter_editor_rows = self.tabs[i] + .scene + .named_parameters() + .iter() + .map(|p| crate::ui::window::named_parameters::ParamEditorRow { name: p.name.clone(), formula: p.source.clone() }) + .collect(); + self.active_modal = Some(super::ModalKind::NamedParameters); + Task::none() + } + Message::NamedParametersInput { idx, field, value } => { + use crate::ui::window::named_parameters::ParamField; + if let Some(row) = self.named_parameter_editor_rows.get_mut(idx) { + match field { + ParamField::Name => row.name = value, + ParamField::Formula => row.formula = value, + } + } + Task::none() + } + Message::NamedParametersAdd => { + self.named_parameter_editor_rows.push(crate::ui::window::named_parameters::ParamEditorRow::default()); + Task::none() + } + Message::NamedParametersRemove(idx) => { + if idx < self.named_parameter_editor_rows.len() { + self.named_parameter_editor_rows.remove(idx); + } + Task::none() + } + Message::NamedParametersApply => { + self.apply_named_parameter_editor_rows(); + Task::none() + } + + // ── Parameters / Constraints sections embedded in the + // Properties panel ────────────────────────────────────────────── + Message::PropParamInput { index, field, value } => { + self.tabs[self.active_tab] + .properties + .edit_buf + .insert(crate::ui::properties::FieldKey::Param(index, field), value); + Task::none() + } + Message::PropParamCommit { index, field } => self.on_prop_param_commit(index, field), + Message::PropParamDelete(index) => self.on_prop_param_delete(index), + Message::PropParamAddNew => self.on_prop_param_add_new(), + Message::PropConstraintLinkClick(handles) => { + let i = self.active_tab; + self.tabs[i].scene.deselect_all(); + for h in handles { + self.tabs[i].scene.select_entity(h, false); + } + self.refresh_properties(); + Task::none() + } + // ── Options / About windows ─────────────────────────────────── Message::OptionsOpen => { self.active_modal = Some(super::ModalKind::Options); @@ -6448,6 +6511,16 @@ 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(); + Task::none() + } Message::LanguageChanged(language) => { if self.language == language { return Task::none(); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 606ffa871..b9bcf7aab 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1246,7 +1246,7 @@ impl OpenCADStudio { }; let mut seen_handles = rustc_hash::FxHashSet::default(); - let edited_handles: Vec<_> = grip + let mut edited_handles: Vec<_> = grip .targets .iter() .map(|target| target.handle) @@ -1706,6 +1706,29 @@ 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); + 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() { + self.grip_originals.push((handle, original)); + } + self.grip_preview_handles.push(handle); + if !self.tabs[i].scene.meshes.contains_key(&handle) { + self.tabs[i].scene.preview_hidden.insert(handle); + } + } + if !edited_handles.contains(&handle) { + edited_handles.push(handle); + } + } + for (handle, new_entity) in solved_by_constraints { + if let Some(slot) = self.tabs[i].scene.document.get_entity_mut(handle) { + *slot = new_entity; + } + } let mesh_changes: Vec<_> = edited_handles .iter() .copied() @@ -3887,6 +3910,7 @@ impl OpenCADStudio { } else if self.tabs[i].active_cmd.as_ref() .is_some_and(|command| command.entity_pick_accepts_points()) { + self.refresh_command_point_pick_context(i); self.tabs[i].active_cmd.as_mut().map(|command| command.on_point(pick_wcs)) } else if self.tabs[i] .active_cmd @@ -3963,6 +3987,7 @@ impl OpenCADStudio { // (LINE tangent to two circles, which needs both). When // it does, sync last_point to the command's resolved // anchor since it replaced the picked coordinate. + self.refresh_command_point_pick_context(i); let handled = self.tabs[i] .active_cmd .as_mut() diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 23ec26ce1..263b60b65 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -805,6 +805,50 @@ 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, 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) { + 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_map(|c| { + let anchor = crate::scene::sketch_constraints::glyph_anchor(&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 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, label, is_conflicting)) + }) + .collect() + } + _ => Vec::new(), + } + }; crate::ui::overlay::selection_overlay( std::sync::Arc::clone(&tab.scene.selection), snap_info, @@ -847,6 +891,7 @@ bg={bg_ms:.1}ms n={view_count}" grip_hot: self.model_space.grip_hot, grip_hover: self.model_space.grip_hover, }, + constraint_glyphs, ) }; @@ -2091,6 +2136,8 @@ 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), ) }) .width(Fill) diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 0f59969ed..5e586d1dd 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -13,6 +13,7 @@ impl OpenCADStudio { Some(K::About) => crate::tr!("modal", "about"), Some(K::Shortcuts) => crate::tr!("modal", "keyboard-shortcuts"), Some(K::Aliases) => crate::tr!("modal", "command-aliases"), + Some(K::NamedParameters) => crate::t!("Named Parameters").into_owned(), Some(K::Options) => crate::tr!("action", "options"), Some(K::FindReplace) => crate::tr!("modal", "find-replace"), Some(K::PluginManager) => crate::tr!("modal", "plugin-manager"), @@ -173,6 +174,21 @@ impl OpenCADStudio { }, ) } + super::super::ModalKind::NamedParameters => { + let scene = &self.tabs[self.active_tab].scene; + sized_flow( + ex, + 820, + 520, + |flow| { + crate::ui::window::named_parameters::view_window( + &self.named_parameter_editor_rows, + scene, + flow, + ) + }, + ) + } super::super::ModalKind::Options => sized_flow( ex, 880, @@ -181,6 +197,8 @@ 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, self.language, diff --git a/src/command.rs b/src/command.rs index d69859462..554aceb20 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1408,6 +1408,60 @@ pub enum CmdResult { ReplaceMany(Vec<(Handle, Vec)>, Vec), /// Replace several entities as one undo step while keeping the command active. ReplaceManyContinue(Vec<(Handle, Vec)>), + /// Add a persistent parametric constraint to the current scope's + /// `SketchConstraintSet` and trigger its + /// first solve; end the command. Unlike `ReplaceMany`/`CommitEntity`, + /// nothing here is geometry to add or replace directly — the host adds + /// the constraint record, then re-solves through `Scene::bump_entities`'s + /// existing chain (`refresh_sketch_constraints`) the same way any later + /// edit to these entities will. + AddSketchConstraint { + kind: crate::scene::sketch_constraints::ConstraintKind, + refs: Vec, + /// The typed target for a dimensional kind (Distance/Angle/Radius) — + /// a literal number, or a named-parameter reference recognized by + /// `DistanceConstraintCommand`/`AngleConstraintCommand:: + /// on_text_input` when the typed token matches a known parameter + /// name instead of parsing as a number); `None` for a purely + /// geometric kind. + driving_param: Option, + /// Undo-history label, e.g. `"Horizontal constraint"`. + label: &'static str, + }, + /// Adds a Coincident constraint between the sub-entity points nearest + /// the two picked positions. The host resolves the raw points because + /// `CadCommand` has no document access. + AddCoincidentConstraint { + point_a: DVec3, + point_b: DVec3, + label: &'static str, + }, + /// Add a persistent `CenterPoint`/`Midpoint`/`PointOnCurve` constraint + /// (`crate::modules::draw::constrain::point_on_entity`) between one + /// picked point and `target`, a whole entity selected before the tool + /// ran. Like `AddCoincidentConstraint`, the host resolves `point` via + /// `sketch_constraints::nearest_sketch_point` (a `CadCommand` has no + /// document access) and, for `CenterPoint` specifically, addresses + /// `target` via its center marker rather than as a whole entity — see + /// `ConstraintKind::CenterPoint`'s own doc comment for why it's the + /// same solve as `Coincident`/`Concentric` under a different DWG-native + /// class name. + AddPointOnEntityConstraint { + point: DVec3, + target: Handle, + kind: crate::scene::sketch_constraints::ConstraintKind, + label: &'static str, + }, + /// Add a persistent `EqualDistance` constraint + /// (`crate::modules::draw::constrain::equal_distance`): the distance + /// between `points[0]`/`points[1]` equals the distance between + /// `points[2]`/`points[3]`. The host resolves each point via + /// `sketch_constraints::nearest_sketch_point`, same reasoning as + /// `AddCoincidentConstraint`. + AddEqualDistanceConstraint { + points: [DVec3; 4], + label: &'static str, + }, /// Attach one smart centre mark to a newly selected circular source. ReassociateCenterMark { target: Handle, @@ -1475,6 +1529,8 @@ pub enum CmdResult { BreakEntity { handle: Handle, p1: DVec3, p2: DVec3 }, /// Attempt to join the given entities into fewer merged entities. JoinEntities(Vec), + /// Join candidates into an explicitly selected source. + JoinToSource { source: Handle, handles: Vec }, /// Apply a polyline-edit operation to one entity; keep command active. PeditOp { handle: Handle, @@ -1939,7 +1995,21 @@ impl InputKind { } } +/// Current screen projection and configured aperture for point-feature picking. +#[derive(Clone, Copy)] +pub struct PointPickContext { + pub view: glam::Mat4, + pub eye: DVec3, + pub bounds: iced::Rectangle, + pub aperture_px: f32, +} + pub trait CadCommand: Send { + /// Preserve source appearance for commands that extract existing entities. + fn preserve_commit_style(&self) -> bool { false } + fn nested_copy_bind_setting(&self) -> Option { None } + /// Symbol localization is committed together with the extracted entities. + fn nested_copy_symbol_names(&self) -> Option<&acadrust::nested_copy::NestedCopySymbolNames> { None } /// Keep the layer already carried by entities committed by this command /// instead of replacing it with the current drawing layer. fn preserve_commit_layer(&self) -> bool { @@ -2006,6 +2076,12 @@ pub trait CadCommand: Send { /// Called when the user left-clicks in the viewport (point pick). fn on_point(&mut self, pt: DVec3) -> CmdResult; + /// Opt in only while a point input selects an existing point feature. + fn wants_point_pick_context(&self) -> bool { false } + + /// Refreshed for each point input so zoom and viewport changes are reflected. + fn set_point_pick_context(&mut self, _context: Option) {} + /// Called when the user presses Enter (finalize / next option). fn on_enter(&mut self) -> CmdResult; @@ -2286,6 +2362,9 @@ pub trait CadCommand: Send { CmdResult::Cancel } + /// Exclude locked-layer entities from injected selection geometry. + fn selection_entities_exclude_locked(&self) -> bool { false } + fn inject_selection_entities(&mut self, _entities: Vec) {} fn area_preview_regions(&self) -> Option> { @@ -2470,6 +2549,40 @@ pub fn all_registered_command_names() -> Vec<&'static str> { .collect() } +#[cfg(test)] +mod constraint_registry_tests { + use super::*; + + /// Ensures every constraint command remains discoverable by autocomplete + /// and command listings. + #[test] + fn every_constraint_command_is_in_the_autocomplete_registry() { + let names = all_registered_command_names(); + for id in [ + "CCONSTRAINT", + "EDCONSTRAINT", + "CPCONSTRAINT", + "MPCONSTRAINT", + "OCCONSTRAINT", + "HCONSTRAINT", + "VCONSTRAINT", + "PCONSTRAINT", + "QCONSTRAINT", + "ECONSTRAINT", + "TCONSTRAINT", + "NCONSTRAINT", + "NRCONSTRAINT", + "LCONSTRAINT", + "FXCONSTRAINT", + "SYCONSTRAINT", + "DCONSTRAINT", + "ACONSTRAINT", + ] { + assert!(names.contains(&id), "{id} is missing from the command registry"); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -2508,4 +2621,3 @@ mod tests { assert!(names.contains(&"M2P")); } } - diff --git a/src/entities/helix.rs b/src/entities/helix.rs index aa753c6e3..e78643321 100644 --- a/src/entities/helix.rs +++ b/src/entities/helix.rs @@ -53,9 +53,16 @@ fn spline_curve(spline: &Spline) -> Option { fn axis_frame(helix: &Helix) -> Option<(Vec3, Vec3, Vec3)> { let base = point(helix.axis_base_point); let axis = point(helix.axis_vector).normalize()?; - let radial = point(helix.start_point) - base; - let start_direction = (radial - axis * radial.dot(axis)).normalize()?; - Some((base, axis, start_direction)) + let tangent = spline_curve(&helix.spline) + .map(|curve| Vec3::from(curve.tangent_at(0.0))) + .and_then(Vec3::normalize) + .or_else(|| perpendicular(axis))?; + let (axis, radial, _) = HelixCurve::frame_from_points(base.to_array(),point(helix.axis_vector).to_array(),point(helix.start_point).to_array(),tangent.to_array())?; + Some((base,Vec3::from(axis),Vec3::from(radial))) +} + +fn base_radius(helix: &Helix) -> Option { + radius_from_axis(helix, point(helix.start_point)) } fn radius_from_axis(helix: &Helix, value: Vec3) -> Option { @@ -65,6 +72,7 @@ fn radius_from_axis(helix: &Helix, value: Vec3) -> Option { } fn top_radius(helix: &Helix) -> f64 { + if helix.turns == 0.0 { return helix.radius; } spline_curve(&helix.spline) .map(|curve| Vec3::from(curve.point_at(1.0))) .and_then(|endpoint| radius_from_axis(helix, endpoint)) @@ -77,7 +85,7 @@ fn kernel_curve(helix: &Helix, top_radius: f64) -> Option { base_center: base.to_array(), axis_direction: axis.to_array(), start_direction: start_direction.to_array(), - base_radius: helix.radius, + base_radius: base_radius(helix)?, top_radius, height: helix.turns * helix.turn_height, turns: helix.turns, @@ -89,6 +97,36 @@ fn kernel_curve(helix: &Helix, top_radius: f64) -> Option { }) } +/// Reverse generating parameters together with the exact stored spline. +pub(crate) fn reversed(helix: &Helix) -> Option { + let original_curve = spline_curve(&helix.spline)?; + let parameters = kernel_curve(helix, top_radius(helix))?.reversed()?; + let mut result = helix.clone(); + result.axis_base_point = vector(Vec3::from(parameters.base_center)); + result.axis_vector = vector(Vec3::from(parameters.axis_direction)); + result.start_point = vector(Vec3::from(original_curve.point_at(1.0))); + result.radius = parameters.top_radius; + // Reverse the evaluated curve itself instead of fitting it again. + let reversed_curve = original_curve.reversed()?; + result.spline.control_points = reversed_curve + .control_points() + .iter() + .map(|point| Vector3::new(point[0], point[1], point[2])) + .collect(); + result.spline.fit_points.reverse(); + result.spline.weights = if helix.spline.weights.is_empty() { + Vec::new() + } else { + reversed_curve.weights().to_vec() + }; + result.spline.knots = reversed_curve.knots().to_vec(); + let begin = helix.spline.begin_tangent; + let end = helix.spline.end_tangent; + result.spline.begin_tangent = Vector3::new(-end.x, -end.y, -end.z); + result.spline.end_tangent = Vector3::new(-begin.x, -begin.y, -begin.z); + Some(result) +} + fn projected_direction(direction: Vec3, axis: Vec3) -> Option { (direction - axis * direction.dot(axis)).normalize() } @@ -105,12 +143,18 @@ fn perpendicular(axis: Vec3) -> Option { } fn rebuild(helix: &mut Helix, top_radius: f64) -> bool { - let Some(curve) = kernel_curve(helix, top_radius) else { + rebuild_with_radial(helix, top_radius, None) +} + +fn rebuild_with_radial(helix: &mut Helix, top_radius: f64, radial: Option) -> bool { + let Some(mut curve) = kernel_curve(helix, top_radius) else { return false; }; + if let Some(radial) = radial { curve.start_direction = radial.to_array(); } let Some(nurbs) = curve.nurbs() else { return false; }; + helix.radius = top_radius; helix.spline.degree = nurbs.degree() as i32; helix.spline.knots = nurbs.knots().to_vec(); helix.spline.control_points = nurbs @@ -127,7 +171,7 @@ fn rebuild(helix: &mut Helix, top_radius: f64) -> bool { helix.spline.flags.closed = false; helix.spline.flags.periodic = false; helix.spline.flags.rational = nurbs.is_rational(); - helix.spline.flags.planar = false; + helix.spline.flags.planar = curve.height == 0.0; helix.spline.flags.linear = false; true } @@ -194,7 +238,7 @@ fn properties(helix: &Helix) -> Vec { edit(t!("Height").as_ref(), "height", height), edit(t!("Turns").as_ref(), "turns", helix.turns), edit(t!("Turn height").as_ref(), "turn_height", helix.turn_height), - edit(t!("Base radius").as_ref(), "base_radius", helix.radius), + edit(t!("Base radius").as_ref(), "base_radius", base_radius(helix).unwrap_or(0.0)), edit(t!("Top radius").as_ref(), "top_radius", top_radius), choice(t!("Twist").as_ref(), "twist", &twist, &twist_options), ro(t!("Turn slope").as_ref(), "turn_slope", format_angle(turn_slope)), @@ -249,10 +293,15 @@ fn apply_geom_prop(helix: &mut Helix, field: &str, value: &str) { helix.axis_base_point = vector(base); helix.start_point = vector(point(helix.start_point) + delta); } - "height" if number.abs() > EPSILON => { + "height" => { + if helix.turns == 0.0 && number != 0.0 { + return; + } + if number < 0.0 { helix.axis_vector = vector(-point(helix.axis_vector)); } + let number = number.abs(); if helix.constraint == HelixConstraint::TurnHeight && helix.turn_height.abs() > EPSILON { let turns = number / helix.turn_height; - if turns <= EPSILON { + if turns < 0.0 { return; } helix.turns = turns; @@ -260,16 +309,18 @@ fn apply_geom_prop(helix: &mut Helix, field: &str, value: &str) { helix.turn_height = number / helix.turns.max(EPSILON); } } - "turns" if number > EPSILON => { - if helix.constraint == HelixConstraint::TurnHeight { + "turns" if number >= 0.0 => { + if number == 0.0 { helix.turns = 0.0; } + else if helix.constraint == HelixConstraint::TurnHeight { helix.turns = number; } else { helix.turns = number; helix.turn_height = old_height / number; } } - "turn_height" if number.abs() > EPSILON => { - if helix.constraint == HelixConstraint::Turns { + "turn_height" if number >= 0.0 => { + if number == 0.0 { helix.turn_height = 0.0; } + else if helix.constraint == HelixConstraint::Turns { helix.turn_height = number; } else { let turns = old_height / number; @@ -280,12 +331,13 @@ fn apply_geom_prop(helix: &mut Helix, field: &str, value: &str) { helix.turns = turns; } } - "base_radius" if number > EPSILON => { + "base_radius" if number >= 0.0 => { let Some((base, _, start_direction)) = axis_frame(helix) else { return; }; - helix.radius = number; helix.start_point = vector(base + start_direction * number); + if !rebuild_with_radial(helix, top, Some(start_direction)) { *helix = original; } + return; } "top_radius" if number >= 0.0 => { if !rebuild(helix, number) { @@ -303,6 +355,7 @@ fn apply_geom_prop(helix: &mut Helix, field: &str, value: &str) { fn apply_grip(helix: &mut Helix, grip_id: usize, apply: GripApply) { let original = helix.clone(); let top = top_radius(helix); + let Some(base_radius) = base_radius(helix) else { return; }; let Some((base, axis, start_direction)) = axis_frame(helix) else { return; }; @@ -334,7 +387,6 @@ fn apply_grip(helix: &mut Helix, grip_id: usize, apply: GripApply) { if radius <= EPSILON { return; } - helix.radius = radius; helix.start_point = vector(base + radial); } (2, GripApply::Absolute(position)) => { @@ -362,7 +414,7 @@ fn apply_grip(helix: &mut Helix, grip_id: usize, apply: GripApply) { *helix = original; return; }; - helix.start_point = vector(base + start_direction * helix.radius); + helix.start_point = vector(base + start_direction * base_radius); } (3, GripApply::Absolute(position)) => { let position = kernel(position); @@ -455,3 +507,98 @@ impl Transformable for Helix { apply_transform(self, transform); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn zero_base_point_helix() -> Helix { + let curve = HelixCurve { + base_center: [1.0, 2.0, 3.0], + axis_direction: [0.0, 0.0, 1.0], + start_direction: [1.0, 0.0, 0.0], + base_radius: 0.0, + top_radius: 2.0, + height: 0.0, + turns: 0.0, + direction: HelixDirection::CounterClockwise, + }; + let nurbs = curve.nurbs().unwrap(); + let mut helix = Helix::new(); + helix.axis_base_point = Vector3::new(1.0, 2.0, 3.0); + helix.axis_vector = Vector3::UNIT_Z; + helix.start_point = helix.axis_base_point; + helix.radius = 2.0; + helix.turns = 0.0; + helix.turn_height = 0.0; + helix.spline.degree = nurbs.degree() as i32; + helix.spline.knots = nurbs.knots().to_vec(); + helix.spline.control_points = nurbs + .control_points() + .iter() + .map(|point| Vector3::new(point[0], point[1], point[2])) + .collect(); + helix + } + + #[test] + fn zero_base_point_locus_retains_an_editable_frame() { + let mut helix = zero_base_point_helix(); + assert_eq!(base_radius(&helix), Some(0.0)); + assert_eq!(top_radius(&helix), 2.0); + + let original = helix.clone(); + apply_geom_prop(&mut helix, "height", "5"); + assert_eq!(helix, original); + } + + #[test] + fn reversing_twice_restores_a_conical_helix_and_radius_roles() { + let curve = HelixCurve { + base_center: [1.0, 2.0, 3.0], + axis_direction: [0.0, 1.0, 1.0], + start_direction: [1.0, 0.0, 0.0], + base_radius: 1.0, + top_radius: 2.5, + height: 4.0, + turns: 1.25, + direction: HelixDirection::Clockwise, + }; + let nurbs = curve.nurbs().expect("valid helix"); + let mut helix = Helix::new(); + helix.axis_base_point = Vector3::new(1.0, 2.0, 3.0); + let axis = Vec3::from(curve.axis_direction).normalize().unwrap(); + helix.axis_vector = vector(axis); + helix.start_point = Vector3::new(2.0, 2.0, 3.0); + helix.radius = curve.top_radius; + helix.turns = curve.turns; + helix.turn_height = curve.height / curve.turns; + helix.handedness = false; + helix.spline.degree = nurbs.degree() as i32; + helix.spline.control_points = nurbs + .control_points() + .iter() + .map(|point| Vector3::new(point[0], point[1], point[2])) + .collect(); + helix.spline.knots = nurbs.knots().to_vec(); + helix.spline.weights = nurbs.weights().to_vec(); + + let once = reversed(&helix).expect("first reversal"); + assert!((once.radius - curve.base_radius).abs() < 1.0e-12); + assert_eq!(once.turns, helix.turns); + assert_eq!(once.turn_height, helix.turn_height); + assert_eq!(once.handedness, helix.handedness); + let original_curve = spline_curve(&helix.spline).unwrap(); + let once_curve = spline_curve(&once.spline).unwrap(); + assert!(Vec3::from(once_curve.point_at(0.0)) + .distance(Vec3::from(original_curve.point_at(1.0))) < 1.0e-9); + + let twice = reversed(&once).expect("second reversal"); + assert!((twice.radius - helix.radius).abs() < 1.0e-12); + let twice_curve = spline_curve(&twice.spline).unwrap(); + for parameter in [0.0, 0.2, 0.5, 0.8, 1.0] { + assert!(Vec3::from(twice_curve.point_at(parameter)) + .distance(Vec3::from(original_curve.point_at(parameter))) < 1.0e-8); + } + } +} diff --git a/src/io/xref.rs b/src/io/xref.rs index 2aee489d0..fd054a784 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -347,6 +347,7 @@ fn merge_xref_into_block( let old = layer.name.clone(); let new = format!("{}|{}", prefix, old); let mut cloned = layer.clone(); + xref_doc.normalize_imported_layer_defaults(&mut cloned); cloned.name = new.clone(); cloned.set_handle(doc.allocate_handle()); doc.layers.add_or_replace(cloned); diff --git a/src/modules/draw/constrain/coincident.rs b/src/modules/draw/constrain/coincident.rs new file mode 100644 index 000000000..8f166d554 --- /dev/null +++ b/src/modules/draw/constrain/coincident.rs @@ -0,0 +1,84 @@ +//! 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 `SketchRef` (`sketch_constraints::nearest_sketch_point`) +//! and adds the constraint, the same split `ReassociateCenterMark` already +//! uses for a similar reason. + +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; + +pub mod coincident_tool { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "CCONSTRAINT", + label: "Coincident", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/coincident.svg" + )), + event: ModuleEvent::Command("CCONSTRAINT".to_string()), + } + } +} + +#[derive(Default)] +pub struct CoincidentConstraintCommand { + first: Option, +} + +impl CoincidentConstraintCommand { + pub fn new() -> Self { + Self::default() + } +} + +impl CadCommand for CoincidentConstraintCommand { + fn name(&self) -> &'static str { + "CCONSTRAINT" + } + + fn prompt(&self) -> String { + if self.first.is_none() { + "COINCIDENT Specify first point:".to_string() + } else { + "COINCIDENT Specify second point:".to_string() + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + match self.first { + None => { + self.first = Some(pt); + CmdResult::NeedPoint + } + Some(first) => CmdResult::AddCoincidentConstraint { + point_a: first, + point_b: pt, + label: "Coincident constraint", + }, + } + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { + names: &["CCONSTRAINT"] +}); diff --git a/src/modules/draw/constrain/equal_distance.rs b/src/modules/draw/constrain/equal_distance.rs new file mode 100644 index 000000000..89287745b --- /dev/null +++ b/src/modules/draw/constrain/equal_distance.rs @@ -0,0 +1,78 @@ +//! EDCONSTRAINT makes the distance between one point pair equal the +//! distance between another pair, rather than comparing whole entities. +//! being equal length/radius. Needs four point picks (unlike the two +//! whole-entity selections `ECONSTRAINT` uses), so — like +//! `CoincidentConstraintCommand` — it accumulates raw points and hands them +//! back for the host to resolve (`CadCommand` has no document access). + +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; + +pub mod equal_distance_tool { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "EDCONSTRAINT", + label: "Equal Distance", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/equal_distance.svg" + )), + event: ModuleEvent::Command("EDCONSTRAINT".to_string()), + } + } +} + +#[derive(Default)] +pub struct EqualDistanceConstraintCommand { + points: Vec, +} + +impl EqualDistanceConstraintCommand { + pub fn new() -> Self { + Self::default() + } +} + +impl CadCommand for EqualDistanceConstraintCommand { + fn name(&self) -> &'static str { + "EDCONSTRAINT" + } + + fn prompt(&self) -> String { + match self.points.len() { + 0 => "EQUAL DISTANCE Specify first point of first pair:".to_string(), + 1 => "EQUAL DISTANCE Specify second point of first pair:".to_string(), + 2 => "EQUAL DISTANCE Specify first point of second pair:".to_string(), + _ => "EQUAL DISTANCE Specify second point of second pair:".to_string(), + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + self.points.push(pt); + if self.points.len() < 4 { + return CmdResult::NeedPoint; + } + let [a, b, c, d] = self.points[..] else { + unreachable!("just checked len == 4") + }; + CmdResult::AddEqualDistanceConstraint { + points: [a, b, c, d], + label: "Equal distance constraint", + } + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { + names: &["EDCONSTRAINT"] +}); diff --git a/src/modules/draw/constrain/mod.rs b/src/modules/draw/constrain/mod.rs new file mode 100644 index 000000000..7c594094d --- /dev/null +++ b/src/modules/draw/constrain/mod.rs @@ -0,0 +1,38 @@ +//! Ribbon tools and typed-value commands for persistent constraints. +//! +//! Every one adds a [`crate::scene::sketch_constraints::SketchConstraint`] +//! to the current scope and lets `Scene::bump_entities` solve it, the same +//! path any later edit to the constrained entities re-solves through +//! (`src/scene/sketch_solve.rs`). By how they gather input: +//! - **Plain selection, no picking** — Horizontal, Vertical, Fixed (select +//! one entity); Parallel, Perpendicular, Equal, Tangent, Colinear, +//! Concentric, Normal (select two); Symmetric (select three: the +//! mirrored pair, then the mirror line) — dispatch straight to +//! `CmdResult::AddSketchConstraint` from `src/app/commands/draw.rs`. +//! - **A typed value** — Distance, Angle (`value.rs`): the target value +//! comes from the command line after the tool is clicked. +//! - **A picked point, entity pre-selected** — CenterPoint, Midpoint, +//! PointOnCurve (`point_on_entity.rs`): the entity side has no ambiguity +//! (select it first, like Distance/Angle's target), but the point side +//! does (which of a line's two endpoints?), so it's picked interactively +//! and resolved by the host. +//! - **Picked points only, nothing pre-selected** — Coincident +//! (`coincident.rs`, two points), EqualDistance (`equal_distance.rs`, +//! four points): every side is a point, so plain selection can't express +//! it at all. + +mod coincident; +mod equal_distance; +mod point_on_entity; +mod tools; +mod value; +pub use coincident::{coincident_tool, CoincidentConstraintCommand}; +pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; +pub use point_on_entity::{ + center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand, +}; +pub use tools::{ + colinear, concentric, equal, fixed, horizontal, normal, parallel, perpendicular, symmetric, + tangent, vertical, +}; +pub use value::{angle_tool, distance_tool, AngleConstraintCommand, DistanceConstraintCommand}; diff --git a/src/modules/draw/constrain/point_on_entity.rs b/src/modules/draw/constrain/point_on_entity.rs new file mode 100644 index 000000000..c3ed411d9 --- /dev/null +++ b/src/modules/draw/constrain/point_on_entity.rs @@ -0,0 +1,117 @@ +//! CENTERPOINT / MIDPOINT / ONCURVE — constraints between one picked point +//! and a whole entity selected *before* the tool runs (same "select first, +//! then invoke" convention `DistanceConstraintCommand` +//! (`src/modules/draw/constrain/value.rs`) already uses for its own target +//! entity). The entity side has no ambiguity (a whole circle, or a whole +//! line whose midpoint/curve is being addressed, not one specific +//! endpoint) so it doesn't need interactive picking; the point side does +//! (which of a line's two endpoints, or some other addressable point), so +//! this command accumulates just that one point pick and hands it back for +//! the host to resolve via `nearest_sketch_point` — the same split +//! `CoincidentConstraintCommand` uses and for the same reason (`CadCommand` +//! has no document access). + +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::sketch_constraints::ConstraintKind; +use acadrust::types::Handle; + +pub mod center_point_tool { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "CPCONSTRAINT", + label: "Center Point", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/center_point.svg" + )), + event: ModuleEvent::Command("CPCONSTRAINT".to_string()), + } + } +} + +pub mod midpoint_tool { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "MPCONSTRAINT", + label: "Midpoint", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/midpoint.svg" + )), + event: ModuleEvent::Command("MPCONSTRAINT".to_string()), + } + } +} + +pub mod point_on_curve_tool { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "OCCONSTRAINT", + label: "Point on Curve", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/point_on_curve.svg" + )), + event: ModuleEvent::Command("OCCONSTRAINT".to_string()), + } + } +} + +/// Shared by all three tools above — only `name`/`kind`/`label` differ. +pub struct PointOnEntityConstraintCommand { + name: &'static str, + kind: ConstraintKind, + target: Handle, + label: &'static str, +} + +impl PointOnEntityConstraintCommand { + pub fn new( + name: &'static str, + kind: ConstraintKind, + target: Handle, + label: &'static str, + ) -> Self { + Self { + name, + kind, + target, + label, + } + } +} + +impl CadCommand for PointOnEntityConstraintCommand { + fn name(&self) -> &'static str { + self.name + } + + fn prompt(&self) -> String { + "Specify point:".to_string() + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + CmdResult::AddPointOnEntityConstraint { + point: pt, + target: self.target, + kind: self.kind, + label: self.label, + } + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { + names: &["CPCONSTRAINT", "MPCONSTRAINT", "OCCONSTRAINT"] +}); diff --git a/src/modules/draw/constrain/tools.rs b/src/modules/draw/constrain/tools.rs new file mode 100644 index 000000000..0f62d0881 --- /dev/null +++ b/src/modules/draw/constrain/tools.rs @@ -0,0 +1,181 @@ +//! Ribbon button definitions for the Constraints group. + +use crate::modules::{IconKind, ModuleEvent, ToolDef}; + +pub mod horizontal { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "HCONSTRAINT", + label: "Horizontal", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/horizontal.svg" + )), + event: ModuleEvent::Command("HCONSTRAINT".to_string()), + } + } +} + +pub mod vertical { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "VCONSTRAINT", + label: "Vertical", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/vertical.svg" + )), + event: ModuleEvent::Command("VCONSTRAINT".to_string()), + } + } +} + +pub mod parallel { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "PCONSTRAINT", + label: "Parallel", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/parallel.svg" + )), + event: ModuleEvent::Command("PCONSTRAINT".to_string()), + } + } +} + +pub mod perpendicular { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "QCONSTRAINT", + label: "Perpendicular", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/perpendicular.svg" + )), + event: ModuleEvent::Command("QCONSTRAINT".to_string()), + } + } +} + +pub mod equal { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "ECONSTRAINT", + label: "Equal", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/equal.svg" + )), + event: ModuleEvent::Command("ECONSTRAINT".to_string()), + } + } +} + +pub mod tangent { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "TCONSTRAINT", + label: "Tangent", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/tangent.svg" + )), + event: ModuleEvent::Command("TCONSTRAINT".to_string()), + } + } +} + +pub mod concentric { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "NCONSTRAINT", + label: "Concentric", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/concentric.svg" + )), + event: ModuleEvent::Command("NCONSTRAINT".to_string()), + } + } +} + +/// A line perpendicular to a circle/arc's tangent at their point of +/// contact — for the Line/Circle-only entity model this is equivalent to +/// "the line passes through the circle's center" (a circle's radius is +/// always normal to its own tangent), so it's built from the same +/// `PointOnLine` primitive `PointOnCurve` already uses for a point-on-line +/// case (`sketch_solve.rs`). Distinct from `Perpendicular`, which only +/// covers line-to-line. +pub mod normal { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "NRCONSTRAINT", + label: "Normal", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/normal.svg" + )), + event: ModuleEvent::Command("NRCONSTRAINT".to_string()), + } + } +} + +pub mod colinear { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "LCONSTRAINT", + label: "Colinear", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/colinear.svg" + )), + event: ModuleEvent::Command("LCONSTRAINT".to_string()), + } + } +} + +pub mod fixed { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "FXCONSTRAINT", + label: "Fixed", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/fixed.svg" + )), + event: ModuleEvent::Command("FXCONSTRAINT".to_string()), + } + } +} + +pub mod symmetric { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "SYCONSTRAINT", + label: "Symmetric", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/symmetric.svg" + )), + event: ModuleEvent::Command("SYCONSTRAINT".to_string()), + } + } +} + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { + names: &[ + "HCONSTRAINT", + "VCONSTRAINT", + "PCONSTRAINT", + "QCONSTRAINT", + "ECONSTRAINT", + "TCONSTRAINT", + "NCONSTRAINT", + "NRCONSTRAINT", + "LCONSTRAINT", + "FXCONSTRAINT", + "SYCONSTRAINT", + ] +}); diff --git a/src/modules/draw/constrain/value.rs b/src/modules/draw/constrain/value.rs new file mode 100644 index 000000000..e7262176e --- /dev/null +++ b/src/modules/draw/constrain/value.rs @@ -0,0 +1,639 @@ +//! DCONSTRAINT / ACONSTRAINT — constraints that need a typed target value +//! (a distance or an angle), unlike the plain select-and-click constraints +//! in `mod.rs`. +//! +//! 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 +//! 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 +//! submits) is still computed from a one-time read of current geometry — +//! that part doesn't change. + +use acadrust::entities::EntityType; +use acadrust::types::Handle; +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult, InputKind}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::named_parameters::DrivingValue; +use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; +use crate::scene::Scene; + +/// Recognizes a typed prompt token as either a numeric literal or a +/// reference to an existing named parameter. `known_names` is a one-time snapshot taken at +/// command construction (`DistanceConstraintCommand`/`AngleConstraintCommand +/// ::new`, which already has `&Scene`) since `on_text_input` itself has no +/// document access — the same constraint `default_value` already works +/// around. Only *existence* is checked here; the actual value is resolved +/// fresh at solve time (`sketch_solve::build_constraint`), consistent with +/// this project's "no incremental/cached resolution" approach throughout. +fn parse_driving_value(text: &str, known_names: &[String]) -> Option { + let text = text.trim(); + if let Ok(value) = text.parse::() { + return Some(DrivingValue::Literal(value)); + } + // Case-insensitive: the command line uppercases `SingleToken` input + // (InputKind::SingleToken) before it reaches this parser, so a typed + // lowercase parameter name like "hole_dia" arrives here as "HOLE_DIA". + // Matching case-insensitively and returning the canonically-cased name + // keeps the driving link pointed at the actual parameter. + known_names + .iter() + .find(|n| n.eq_ignore_ascii_case(text)) + .map(|n| DrivingValue::Named(n.clone())) +} + +pub mod distance_tool { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "DCONSTRAINT", + label: "Distance", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/distance.svg" + )), + event: ModuleEvent::Command("DCONSTRAINT".to_string()), + } + } +} + +pub mod angle_tool { + use super::*; + pub fn tool() -> ToolDef { + ToolDef { + id: "ACONSTRAINT", + label: "Angle", + icon: IconKind::Svg(include_bytes!( + "../../../../assets/icons/constrain/angle.svg" + )), + event: ModuleEvent::Command("ACONSTRAINT".to_string()), + } + } +} + +/// Which flavor of dimensional constraint the typed value drives. +#[derive(Clone, Copy, PartialEq, Eq)] +enum DistanceMode { + /// Line length (two-point distance) or a circle/arc's radius — + /// whichever `is_circle` selects. + Auto, + X, + Y, + Diameter, + /// Arc length — only offered when `is_arc` (not a plain Circle, which + /// has no "arc length" distinct from its circumference). + ArcLength, +} + +/// Constrains a single line's length (or its X/Y-only component) between +/// its two endpoints, or a circle/arc's radius (or diameter, or — for an +/// arc specifically — its swept arc length), to a typed target value. +pub struct DistanceConstraintCommand { + handle: Handle, + /// Whether `handle` is a circle/arc (Radius/Diameter) or a line + /// (Distance/DistanceX/DistanceY) — decided once at construction from + /// the entity's type. + is_circle: bool, + /// Whether `handle` is specifically an Arc (as opposed to a full + /// Circle) — gates the `[Arclength]` prompt option. + is_arc: bool, + mode: DistanceMode, + default_value: f64, + 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, +} + +impl DistanceConstraintCommand { + /// `None` if `handle` isn't a Line, Circle, or Arc. + pub fn new(scene: &Scene, handle: Handle) -> Option { + let entity = scene.document.get_entity(handle)?; + let (is_circle, is_arc, default_value, default_x, default_y, default_arc_length) = + match entity { + EntityType::Line(l) => { + let dx = l.end.x - l.start.x; + let dy = l.end.y - l.start.y; + (false, false, (dx * dx + dy * dy).sqrt(), dx, dy, 0.0) + } + EntityType::Circle(c) => (true, false, c.radius, 0.0, 0.0, 0.0), + EntityType::Arc(a) => (true, true, a.radius, 0.0, 0.0, a.arc_length()), + _ => return None, + }; + let known_param_names = scene + .named_parameters() + .iter() + .map(|p| p.name.clone()) + .collect(); + Some(Self { + handle, + is_circle, + is_arc, + mode: DistanceMode::Auto, + default_value, + default_x, + default_y, + default_arc_length, + known_param_names, + }) + } + + fn build(&self, target: DrivingValue) -> Option { + if let DrivingValue::Literal(v) = &target { + let signed_component = matches!(self.mode, DistanceMode::X | DistanceMode::Y); + if !v.is_finite() || (!signed_component && *v <= 0.0) { + return None; + } + } + let (kind, refs, label) = match (self.is_circle, self.mode) { + (true, DistanceMode::Diameter) => ( + ConstraintKind::Diameter, + vec![SketchRef::whole(self.handle)], + "Diameter constraint", + ), + (true, DistanceMode::ArcLength) if self.is_arc => ( + ConstraintKind::ArcLength, + vec![SketchRef::whole(self.handle)], + "Arc length constraint", + ), + (true, _) => ( + ConstraintKind::Radius, + vec![SketchRef::whole(self.handle)], + "Radius constraint", + ), + (false, DistanceMode::X) => ( + ConstraintKind::DistanceX, + vec![ + SketchRef::point(self.handle, 0), + SketchRef::point(self.handle, 1), + ], + "DistanceX constraint", + ), + (false, DistanceMode::Y) => ( + ConstraintKind::DistanceY, + vec![ + SketchRef::point(self.handle, 0), + SketchRef::point(self.handle, 1), + ], + "DistanceY constraint", + ), + (false, _) => ( + ConstraintKind::Distance, + vec![ + SketchRef::point(self.handle, 0), + SketchRef::point(self.handle, 1), + ], + "Distance constraint", + ), + }; + Some(CmdResult::AddSketchConstraint { + kind, + refs, + driving_param: Some(target), + label, + }) + } +} + +impl CadCommand for DistanceConstraintCommand { + fn name(&self) -> &'static str { + "DCONSTRAINT" + } + + fn prompt(&self) -> String { + match (self.is_circle, self.mode) { + (true, DistanceMode::Diameter) => format!( + "Specify diameter <{:.4}> or [Radius]: ", + self.default_value * 2.0 + ), + (true, DistanceMode::ArcLength) if self.is_arc => { + format!( + "Specify arc length <{:.4}> or [Radius]: ", + self.default_arc_length + ) + } + (true, _) if self.is_arc => format!( + "Specify distance <{:.4}> or [Diameter/Arclength]: ", + self.default_value + ), + (true, _) => format!( + "Specify distance <{:.4}> or [Diameter]: ", + self.default_value + ), + (false, DistanceMode::X) => format!("Specify X distance <{:.4}>: ", self.default_x), + (false, DistanceMode::Y) => format!("Specify Y distance <{:.4}>: ", self.default_y), + (false, _) => format!( + "Specify distance <{:.4}> or [Xdistance/Ydistance]: ", + self.default_value + ), + } + } + + fn input_kind(&self) -> InputKind { + InputKind::SingleToken + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + let default = match self.mode { + DistanceMode::Diameter => self.default_value * 2.0, + DistanceMode::ArcLength if self.is_arc => self.default_arc_length, + DistanceMode::X => self.default_x, + DistanceMode::Y => self.default_y, + _ => self.default_value, + }; + self.build(DrivingValue::Literal(default)) + .unwrap_or(CmdResult::Cancel) + } + + fn on_text_input(&mut self, text: &str) -> Option { + // Mode-switch keywords re-prompt instead of building — consumed + // inputs return `Some(NeedPoint)` so the driver doesn't re-offer + // the same token a second time (matches e.g. `trim.rs`'s + // `T`/`B`/`F` keyword handling). Defensively uppercased same as + // `trim.rs` even though `SingleToken` input already arrives + // uppercased from the command line. + let keyword = text.trim().to_uppercase(); + match (self.is_circle, keyword.as_str()) { + (true, "D" | "DIAMETER") => { + self.mode = DistanceMode::Diameter; + return Some(CmdResult::NeedPoint); + } + (true, "A" | "ARCLENGTH") if self.is_arc => { + self.mode = DistanceMode::ArcLength; + return Some(CmdResult::NeedPoint); + } + (true, "R" | "RADIUS") => { + self.mode = DistanceMode::Auto; + return Some(CmdResult::NeedPoint); + } + (false, "X" | "XDISTANCE") => { + self.mode = DistanceMode::X; + return Some(CmdResult::NeedPoint); + } + (false, "Y" | "YDISTANCE") => { + self.mode = DistanceMode::Y; + return Some(CmdResult::NeedPoint); + } + _ => {} + } + let value = parse_driving_value(text, &self.known_param_names)?; + self.build(value) + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +/// Constrains the angle (in degrees) from `fixed`'s direction to `moving`'s. +pub struct AngleConstraintCommand { + fixed_handle: Handle, + moving_handle: Handle, + default_value: f64, + /// Snapshot of `Scene::named_parameters`' names at construction time — + /// see `parse_driving_value`'s doc comment for why a snapshot. + known_param_names: Vec, +} + +impl AngleConstraintCommand { + /// `None` unless both `fixed` and `moving` are lines. + pub fn new(scene: &Scene, fixed: Handle, moving: Handle) -> Option { + let fixed_entity = scene.document.get_entity(fixed)?; + let moving_entity = scene.document.get_entity(moving)?; + let (EntityType::Line(f), EntityType::Line(m)) = (fixed_entity, moving_entity) else { + return None; + }; + let a1 = (f.end.y - f.start.y).atan2(f.end.x - f.start.x); + let a2 = (m.end.y - m.start.y).atan2(m.end.x - m.start.x); + let default_value = (a2 - a1).to_degrees(); + let known_param_names = scene + .named_parameters() + .iter() + .map(|p| p.name.clone()) + .collect(); + Some(Self { + fixed_handle: fixed, + moving_handle: moving, + default_value, + known_param_names, + }) + } + + fn build(&self, target: DrivingValue) -> Option { + if matches!(&target, DrivingValue::Literal(value) if !value.is_finite()) { + return None; + } + Some(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Angle, + refs: vec![ + SketchRef::whole(self.fixed_handle), + SketchRef::whole(self.moving_handle), + ], + driving_param: Some(target), + label: "Angle constraint", + }) + } +} + +impl CadCommand for AngleConstraintCommand { + fn name(&self) -> &'static str { + "ACONSTRAINT" + } + + fn prompt(&self) -> String { + format!("Specify angle in degrees <{:.4}>: ", self.default_value) + } + + fn input_kind(&self) -> InputKind { + InputKind::SingleToken + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + self.build(DrivingValue::Literal(self.default_value)) + .unwrap_or(CmdResult::Cancel) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let value = parse_driving_value(text, &self.known_param_names)?; + self.build(value) + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { + names: &["DCONSTRAINT", "ACONSTRAINT"] +}); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_driving_value_prefers_a_numeric_literal() { + let known = vec!["hole_dia".to_string()]; + assert_eq!( + parse_driving_value("12.5", &known), + Some(DrivingValue::Literal(12.5)) + ); + // Distance's own `build` rejects a non-positive target later; the + // parse step itself accepts any number, negative included. + assert_eq!( + parse_driving_value("-3", &known), + Some(DrivingValue::Literal(-3.0)) + ); + } + + #[test] + fn parse_driving_value_recognizes_a_known_parameter_name() { + let known = vec!["hole_dia".to_string(), "plate_len".to_string()]; + assert_eq!( + parse_driving_value("hole_dia", &known), + Some(DrivingValue::Named("hole_dia".to_string())) + ); + } + + #[test] + fn parse_driving_value_rejects_an_unknown_token() { + let known = vec!["hole_dia".to_string()]; + assert_eq!(parse_driving_value("bogus", &known), None); + assert_eq!(parse_driving_value("", &known), None); + } + + #[test] + fn parse_driving_value_matches_a_known_parameter_regardless_of_typed_case() { + // The command line uppercases `SingleToken` input before it reaches + // here, so a lowercase-named parameter must still resolve — and + // resolve to its canonically-cased name, not the uppercased token. + let known = vec!["hole_dia".to_string()]; + assert_eq!( + parse_driving_value("HOLE_DIA", &known), + Some(DrivingValue::Named("hole_dia".to_string())) + ); + assert_eq!( + parse_driving_value("Hole_Dia", &known), + Some(DrivingValue::Named("hole_dia".to_string())) + ); + } + + fn add_line(scene: &mut Scene) -> Handle { + scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(6.0, 8.0, 0.0), + ))) + } + + fn add_circle(scene: &mut Scene) -> Handle { + scene.add_entity(EntityType::Circle( + acadrust::entities::Circle::from_center_radius( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + 3.0, + ), + )) + } + + fn add_arc(scene: &mut Scene) -> Handle { + scene.add_entity(EntityType::Arc( + acadrust::entities::Arc::from_center_radius_angles( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + 3.0, + 0.0, + std::f64::consts::FRAC_PI_2, + ), + )) + } + + #[test] + fn x_keyword_switches_mode_and_then_builds_a_distance_x_constraint() { + let mut scene = Scene::new(); + let line = add_line(&mut scene); + let mut cmd = + DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT"); + + // The keyword itself only switches mode and re-prompts — it must + // not build a constraint yet. + assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint))); + assert!( + cmd.prompt().contains("X distance"), + "prompt should reflect the new mode: {}", + cmd.prompt() + ); + + match cmd.on_text_input("10") { + Some(CmdResult::AddSketchConstraint { + kind, + driving_param: Some(DrivingValue::Literal(v)), + .. + }) => { + assert_eq!(kind, ConstraintKind::DistanceX); + assert_eq!(v, 10.0); + } + _ => panic!("expected an AddSketchConstraint{{DistanceX}}, got a different result"), + } + } + + #[test] + fn component_distances_accept_signed_values() { + let mut scene = Scene::new(); + let line = add_line(&mut scene); + let mut cmd = + DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT"); + assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint))); + assert!(matches!( + cmd.on_text_input("-3"), + Some(CmdResult::AddSketchConstraint { + kind: ConstraintKind::DistanceX, + driving_param: Some(DrivingValue::Literal(-3.0)), + .. + }) + )); + } + + #[test] + fn component_distance_defaults_use_the_selected_axis() { + let mut scene = Scene::new(); + let line = add_line(&mut scene); + let mut cmd = + DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT"); + assert!(matches!(cmd.on_text_input("Y"), Some(CmdResult::NeedPoint))); + assert!(cmd.prompt().contains("<8.0000>")); + assert!(matches!( + cmd.on_enter(), + CmdResult::AddSketchConstraint { + kind: ConstraintKind::DistanceY, + driving_param: Some(DrivingValue::Literal(8.0)), + .. + } + )); + } + + #[test] + fn ordinary_distances_reject_nonpositive_and_nonfinite_values() { + let mut scene = Scene::new(); + let line = add_line(&mut scene); + let mut cmd = + DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT"); + assert!(cmd.on_text_input("-3").is_none()); + assert!(cmd.build(DrivingValue::Literal(f64::INFINITY)).is_none()); + } + + #[test] + fn d_keyword_switches_a_circles_command_to_diameter_mode() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene); + let mut cmd = + DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); + + assert!(matches!(cmd.on_text_input("D"), Some(CmdResult::NeedPoint))); + assert!( + cmd.prompt().contains("diameter"), + "prompt should reflect Diameter mode: {}", + cmd.prompt() + ); + + match cmd.on_text_input("16") { + Some(CmdResult::AddSketchConstraint { + kind, + driving_param: Some(DrivingValue::Literal(v)), + .. + }) => { + assert_eq!(kind, ConstraintKind::Diameter); + assert_eq!(v, 16.0); + } + _ => panic!("expected an AddSketchConstraint{{Diameter}}"), + } + } + + #[test] + fn default_mode_still_builds_a_plain_radius_constraint() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene); + let mut cmd = + DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); + + match cmd.on_text_input("5") { + Some(CmdResult::AddSketchConstraint { kind, .. }) => { + assert_eq!(kind, ConstraintKind::Radius) + } + _ => panic!("expected an AddSketchConstraint{{Radius}}"), + } + } + + #[test] + fn a_keyword_switches_an_arcs_command_to_arc_length_mode() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene); + let mut cmd = + DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT"); + + assert!(matches!(cmd.on_text_input("A"), Some(CmdResult::NeedPoint))); + assert!( + cmd.prompt().contains("arc length"), + "prompt should reflect ArcLength mode: {}", + cmd.prompt() + ); + + match cmd.on_text_input("8") { + Some(CmdResult::AddSketchConstraint { + kind, + driving_param: Some(DrivingValue::Literal(v)), + .. + }) => { + assert_eq!(kind, ConstraintKind::ArcLength); + assert_eq!(v, 8.0); + } + _ => panic!("expected an AddSketchConstraint{{ArcLength}}"), + } + } + + #[test] + fn arc_length_keyword_is_not_offered_for_a_plain_circle() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene); + let mut cmd = + DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); + + assert!( + !cmd.prompt().contains("Arclength"), + "a plain circle has no arc length option: {}", + cmd.prompt() + ); + // "A" isn't a recognized keyword here and doesn't parse as a + // number or a named parameter either, so it's rejected outright. + assert!(cmd.on_text_input("A").is_none()); + } + + #[test] + fn arc_length_default_is_the_arcs_current_swept_length() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene); // radius 3, 90° sweep -> length = 3 * pi/2 + let mut cmd = + DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT"); + cmd.on_text_input("A"); + + let expected = 3.0 * std::f64::consts::FRAC_PI_2; + assert!( + cmd.prompt().contains(&format!("{expected:.4}")), + "prompt should default to the arc's current length: {}", + cmd.prompt() + ); + } +} diff --git a/src/modules/draw/draw/helix.rs b/src/modules/draw/draw/helix.rs index fbeb2ca63..20860e96f 100644 --- a/src/modules/draw/draw/helix.rs +++ b/src/modules/draw/draw/helix.rs @@ -122,7 +122,7 @@ impl HelixCommand { .iter() .map(|point| Vector3::new(point[0], point[1], point[2])) .collect(); - spline.flags.planar = false; + spline.flags.planar = height == 0.0; spline.flags.rational = nurbs.is_rational(); if spline.flags.rational { spline.weights = nurbs.weights().to_vec(); @@ -134,9 +134,9 @@ impl HelixCommand { let start = self.center + start_direction * curve.base_radius; helix.start_point = Vector3::new(start.x, start.y, start.z); helix.axis_vector = Vector3::new(axis.x, axis.y, axis.z); - helix.radius = curve.base_radius; + helix.radius = curve.top_radius; helix.turns = curve.turns; - helix.turn_height = height / curve.turns; + helix.turn_height = if curve.turns == 0.0 { 0.0 } else { height / curve.turns }; helix.handedness = self.counter_clockwise; helix.constraint = self.constraint; Some(EntityType::Helix(helix)) @@ -262,7 +262,7 @@ impl CadCommand for HelixCommand { } else { distance }; - if radius > EPSILON { + if radius >= 0.0 { self.base_radius = radius; self.top_radius = radius; self.step = Step::TopRadius; @@ -272,21 +272,20 @@ impl CadCommand for HelixCommand { Step::TopRadius | Step::TopDiameter => { let local = self.plane.vector_to_local(point - self.center); let distance = local.x.hypot(local.y); - self.top_radius = if self.step == Step::TopDiameter { + let top_radius = if self.step == Step::TopDiameter { distance * 0.5 } else { distance }; - self.step = Step::Final; + if self.base_radius > 0.0 || top_radius > 0.0 { + self.top_radius = top_radius; + self.step = Step::Final; + } CmdResult::NeedPoint } Step::Final => { let height = (point - self.center).dot(self.plane.z); - if height.abs() <= EPSILON { - CmdResult::NeedPoint - } else { - self.commit(height, self.plane.z) - } + self.commit(height, self.plane.z) } Step::AxisEndpoint => { let vector = point - self.center; @@ -310,6 +309,9 @@ impl CadCommand for HelixCommand { CmdResult::NeedPoint } Step::TopRadius | Step::TopDiameter => { + if self.base_radius == 0.0 { + return CmdResult::NeedPoint; + } self.top_radius = self.base_radius; self.step = Step::Final; CmdResult::NeedPoint @@ -382,7 +384,7 @@ impl CadCommand for HelixCommand { } Step::BaseRadius => { let radius = Self::parse_value(value)?; - if radius > EPSILON { + if radius >= 0.0 { self.base_radius = radius; self.top_radius = radius; self.step = Step::TopRadius; @@ -391,7 +393,7 @@ impl CadCommand for HelixCommand { } Step::BaseDiameter => { let diameter = Self::parse_value(value)?; - if diameter > EPSILON { + if diameter >= 0.0 { self.base_radius = diameter * 0.5; self.top_radius = self.base_radius; self.step = Step::TopRadius; @@ -400,7 +402,7 @@ impl CadCommand for HelixCommand { } Step::TopRadius => { let radius = Self::parse_value(value)?; - if radius >= 0.0 { + if radius >= 0.0 && (self.base_radius > 0.0 || radius > 0.0) { self.top_radius = radius; self.step = Step::Final; } @@ -408,7 +410,7 @@ impl CadCommand for HelixCommand { } Step::TopDiameter => { let diameter = Self::parse_value(value)?; - if diameter >= 0.0 { + if diameter >= 0.0 && (self.base_radius > 0.0 || diameter > 0.0) { self.top_radius = diameter * 0.5; self.step = Step::Final; } @@ -416,11 +418,15 @@ impl CadCommand for HelixCommand { } Step::Final => { let height = Self::parse_value(value)?; - (height.abs() > EPSILON).then(|| self.commit(height, self.plane.z)) + Some(self.commit(height, self.plane.z)) } Step::AxisEndpoint => None, Step::Turns => { let turns = Self::parse_turns(value)?; + if turns > 500.0 { + self.step = Step::Final; + return Some(CmdResult::NeedPoint); + } if turns > EPSILON { self.turns = turns; self.requested_turn_height = None; @@ -493,3 +499,26 @@ impl CadCommand for HelixCommand { } inventory::submit!(crate::command::CommandRegistration { names: &["HELIX"] }); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero_turn_build_keeps_finite_native_parameters() { + let mut command = HelixCommand::new(); + command.center = DVec3::new(1.0, 2.0, 3.0); + command.base_radius = 2.0; + command.top_radius = 4.0; + command.turns = 0.0; + let EntityType::Helix(helix) = command.build(0.0, DVec3::Z).unwrap() else { + panic!("expected helix"); + }; + + assert_eq!(helix.turn_height, 0.0); + assert!(helix.turn_height.is_finite()); + assert!(helix.spline.flags.planar); + assert_eq!(helix.spline.control_points.len(), 2); + assert_eq!(helix.spline.control_points[0], helix.spline.control_points[1]); + } +} diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index 6318fde7f..d0c0422a7 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -9,7 +9,7 @@ use acadrust::entities::{Ray as RayEnt, XLine as XLineEnt}; use acadrust::types::Vector3; use acadrust::EntityType; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::scene::model::wire_model::WireModel; use glam::DVec3; @@ -50,6 +50,9 @@ impl CadCommand for RayCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + if !pt.is_finite() { + return CmdResult::NeedPoint; + } if let Some(base) = self.base { let dir = pt - base; let len = dir.length(); @@ -123,11 +126,64 @@ impl CadCommand for RayCommand { pub struct XLineCommand { base: Option, + mode: XLineMode, + plane: WorkingPlane, + reference: Option<(DVec3, DVec3)>, + picked: Option, + offset: Option, + value_origin: Option, +} + +#[derive(Clone, Copy)] +enum XLineMode { + Points, + Direction(DVec3), + Angle, + AngleReference, + Bisect, + OffsetDistance, + OffsetPick, + OffsetSide, } impl XLineCommand { pub fn new() -> Self { - Self { base: None } + Self { + base: None, + mode: XLineMode::Points, + plane: WorkingPlane::default(), + reference: None, + picked: None, + offset: None, + value_origin: None, + } + } + + fn geometry(&self, pt: DVec3) -> Option<(DVec3, DVec3)> { + if !pt.is_finite() { return None; } + let (base, direction) = match self.mode { + XLineMode::Points => (self.base?, pt - self.base?), + XLineMode::Direction(dir) => (pt, dir), + XLineMode::Bisect => { + let base = self.base?; + let first = self.reference?.1; + let last = (pt - base).try_normalize()?; + (base, first + last) + } + XLineMode::OffsetSide => { + let (base, dir) = self.reference?; + if let Some(distance) = self.offset { + let normal = self.plane.z.cross(dir).try_normalize()?; + let side = (pt - base).dot(normal); + if side.abs() < 1e-10 { return None; } + (base + normal * distance * side.signum(), dir) + } else { + (pt, dir) + } + } + _ => return None, + }; + Some((base, direction.try_normalize()?)) } } @@ -137,43 +193,168 @@ impl CadCommand for XLineCommand { } fn prompt(&self) -> String { - if self.base.is_none() { - t!("XLINE Specify a point:").into_owned() - } else { - t!("XLINE Specify through point:").into_owned() - } + if self.value_origin.is_some() { return "XLINE Specify second point:".into(); } + match self.mode { + XLineMode::Points if self.base.is_none() => "XLINE Specify a point or [Hor/Ver/Ang/Bisect/Offset]:", + XLineMode::Angle => "XLINE Enter angle of xline <0> or [Reference]:", + XLineMode::AngleReference | XLineMode::OffsetPick => "XLINE Select a line object:", + XLineMode::Bisect if self.base.is_none() => "XLINE Specify angle vertex point:", + XLineMode::Bisect if self.reference.is_none() => "XLINE Specify angle start point:", + XLineMode::Bisect => "XLINE Specify angle end point:", + XLineMode::OffsetDistance => "XLINE Specify offset distance or [Through] :", + XLineMode::OffsetSide if self.offset.is_some() => "XLINE Specify side to offset:", + _ => "XLINE Specify through point:", + }.into() } fn options(&self) -> Vec { use crate::command::CmdOption; - if self.base.is_some() { - vec![CmdOption::enter(t!("Done").as_ref())] - } else { - vec![] + if self.value_origin.is_some() { return vec![]; } + match self.mode { + XLineMode::Points if self.base.is_none() => vec![ + CmdOption::new("Hor", "H"), CmdOption::new("Ver", "V"), + CmdOption::new("Ang", "A"), CmdOption::new("Bisect", "B"), + CmdOption::new("Offset", "O"), + ], + XLineMode::Angle => vec![CmdOption::new("Reference", "R")], + XLineMode::OffsetDistance => vec![CmdOption::new("Through", "T")], + _ => vec![CmdOption::enter(t!("Done").as_ref())], } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - if let Some(base) = self.base { - let dir = pt - base; - let len = dir.length(); - if len < 1e-6 { - return CmdResult::NeedPoint; + if !pt.is_finite() { return CmdResult::NeedPoint; } + if matches!(self.mode, XLineMode::Angle | XLineMode::OffsetDistance) { + if self.value_origin.is_none() { self.value_origin = Some(pt); } + else if let Some(value) = self.dyn_live_value(pt) { + return self.on_text_input(&value.to_string()).unwrap_or(CmdResult::NeedPoint); } - let dir_n = dir / len; - let xline = XLineEnt::new( - Vector3::new(base.x, base.y, base.z), - Vector3::new(dir_n.x, dir_n.y, dir_n.z), - ); - CmdResult::CommitEntity(EntityType::XLine(xline)) - } else { - self.base = Some(pt); - CmdResult::NeedPoint + return CmdResult::NeedPoint; } + if let Some((base, dir)) = self.geometry(pt) { + let entity = XLineEnt::new( + Vector3::new(base.x, base.y, base.z), + Vector3::new(dir.x, dir.y, dir.z), + ); + if matches!(self.mode, XLineMode::OffsetSide) { + self.mode = XLineMode::OffsetPick; + self.reference = None; + } + return CmdResult::CommitEntity(EntityType::XLine(entity)); + } + match self.mode { + XLineMode::Points | XLineMode::Bisect if self.base.is_none() => self.base = Some(pt), + XLineMode::Bisect if self.reference.is_none() => { + if let Some(dir) = (pt - self.base.unwrap()).try_normalize() { + self.reference = Some((self.base.unwrap(), dir)); + } + } + _ => {} + } + CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + if self.value_origin.is_some() { return CmdResult::NeedPoint; } + match self.mode { + XLineMode::Angle => self.on_text_input("0").unwrap_or(CmdResult::NeedPoint), + XLineMode::OffsetDistance => { + self.mode = XLineMode::OffsetPick; + CmdResult::NeedPoint + } + _ => CmdResult::Cancel, + } + } + + fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } + fn wants_text_input(&self) -> bool { true } + fn point_step_accepts_keywords(&self) -> bool { true } + fn dyn_field(&self) -> crate::command::DynField { + match self.mode { + XLineMode::Angle => crate::command::DynField::Angle, + XLineMode::OffsetDistance => crate::command::DynField::Distance, + _ => crate::command::DynField::Point, + } + } + fn dyn_commit_as_text(&self) -> bool { + matches!(self.mode, XLineMode::Angle | XLineMode::OffsetDistance) + } + fn dyn_live_value(&self, cursor: DVec3) -> Option { + let origin = self.value_origin?; + if !cursor.is_finite() { return None; } + match self.mode { + XLineMode::Angle => { + let delta = self.plane.vector_to_local(cursor - origin); + (delta.x.hypot(delta.y) > 1e-10).then(|| delta.y.atan2(delta.x).to_degrees()) + } + XLineMode::OffsetDistance => { + let distance = cadkernel::space::Vec3::from(cursor.to_array()).distance(origin.to_array().into()); + (distance.is_finite() && distance > 0.0).then_some(distance) + } + _ => None, + } + } + fn dyn_auto_sign_angle(&self) -> bool { false } + fn needs_entity_pick(&self) -> bool { + matches!(self.mode, XLineMode::AngleReference | XLineMode::OffsetPick) + } + fn inject_before_entity_pick(&self) -> bool { true } + fn inject_picked_entity(&mut self, entity: EntityType) { self.picked = Some(entity); } + fn on_entity_pick(&mut self, _handle: acadrust::Handle, _pt: DVec3) -> CmdResult { + let xyz = |v: Vector3| DVec3::new(v.x, v.y, v.z); + let reference = match self.picked.take() { + Some(EntityType::Line(line)) => Some((xyz(line.start), xyz(line.end) - xyz(line.start))), + Some(EntityType::Ray(line)) => Some((xyz(line.base_point), xyz(line.direction))), + Some(EntityType::XLine(line)) => Some((xyz(line.base_point), xyz(line.direction))), + _ => None, + }; + if let Some((base, dir)) = reference { + if let Some(dir) = dir.try_normalize() { + self.reference = Some((base, dir)); + self.mode = if matches!(self.mode, XLineMode::AngleReference) { + XLineMode::Angle + } else { XLineMode::OffsetSide }; + } + } + CmdResult::NeedPoint + } + fn on_text_input(&mut self, text: &str) -> Option { + let key = text.trim().to_ascii_uppercase(); + match self.mode { + XLineMode::Points if self.base.is_none() => { + self.mode = match key.as_str() { + "H" | "HOR" => XLineMode::Direction(self.plane.x), + "V" | "VER" => XLineMode::Direction(self.plane.y), + "A" | "ANG" => XLineMode::Angle, + "B" | "BISECT" => XLineMode::Bisect, + "O" | "OFFSET" => XLineMode::OffsetDistance, + _ => return None, + }; + } + XLineMode::Angle => { + if key == "R" || key == "REFERENCE" { + if self.value_origin.is_some() { return None; } + self.mode = XLineMode::AngleReference; + } else { + let angle: f64 = key.parse().ok().filter(|v: &f64| v.is_finite())?; + let (sin, cos) = angle.to_radians().sin_cos(); + let axis = self.reference.map_or(self.plane.x, |(_, dir)| dir); + self.mode = XLineMode::Direction(axis * cos + self.plane.z.cross(axis) * sin); + } + } + XLineMode::OffsetDistance => { + self.offset = if key == "T" || key == "THROUGH" { + if self.value_origin.is_some() { return None; } + None + } else { + Some(key.parse::().ok().filter(|v| v.is_finite() && *v > 0.0)?) + }; + self.mode = XLineMode::OffsetPick; + } + _ => return None, + } + self.value_origin = None; + Some(CmdResult::NeedPoint) } fn on_escape(&mut self) -> CmdResult { @@ -181,9 +362,9 @@ impl CadCommand for XLineCommand { } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let pt = pt.as_vec3(); - let base = self.base?.as_vec3(); - let dir = (pt - base).normalize_or_zero(); + let (base, dir) = self.geometry(pt)?; + let base = base.as_vec3(); + let dir = dir.as_vec3(); let far_pos = base + dir * DISPLAY_EXTENT; let far_neg = base - dir * DISPLAY_EXTENT; Some(WireModel { @@ -230,6 +411,13 @@ impl CadCommand for XLineCommand { mod tests { use super::*; + fn direction(entity: EntityType) -> DVec3 { + let EntityType::XLine(entity) = entity else { + panic!("expected a construction line"); + }; + DVec3::new(entity.direction.x, entity.direction.y, entity.direction.z) + } + #[test] fn repeated_rays_and_xlines_keep_their_first_point() { let base = DVec3::new(2.0, 3.0, 4.0); @@ -250,6 +438,89 @@ mod tests { assert_eq!(entity.base_point, Vector3::new(base.x, base.y, base.z)); } } + + #[test] + fn ray_rejects_nonfinite_points_without_losing_its_base() { + let mut ray = RayCommand::new(); + assert!(matches!(ray.on_point(DVec3::splat(f64::NAN)), CmdResult::NeedPoint)); + let base = DVec3::new(1.0, 2.0, 3.0); + assert!(matches!(ray.on_point(base), CmdResult::NeedPoint)); + assert!(matches!(ray.on_point(DVec3::splat(f64::INFINITY)), CmdResult::NeedPoint)); + + let CmdResult::CommitEntity(EntityType::Ray(entity)) = ray.on_point(base + DVec3::X) + else { + panic!("valid direction must commit a ray"); + }; + assert_eq!(entity.base_point, Vector3::new(base.x, base.y, base.z)); + assert_eq!(entity.direction, Vector3::new(1.0, 0.0, 0.0)); + } + + #[test] + fn xline_horizontal_and_two_point_angle_use_the_working_plane() { + let plane = WorkingPlane::new(DVec3::ZERO, DVec3::Y, DVec3::Z); + let mut horizontal = XLineCommand::new(); + horizontal.set_working_plane(plane); + assert!(matches!(horizontal.on_text_input("H"), Some(CmdResult::NeedPoint))); + let CmdResult::CommitEntity(entity) = horizontal.on_point(DVec3::new(3.0, 4.0, 5.0)) + else { + panic!("horizontal mode must commit immediately"); + }; + assert!(direction(entity).abs_diff_eq(plane.x, 1.0e-12)); + + let mut angle = XLineCommand::new(); + angle.set_working_plane(plane); + assert!(matches!(angle.on_text_input("A"), Some(CmdResult::NeedPoint))); + assert!(matches!(angle.on_point(DVec3::ZERO), CmdResult::NeedPoint)); + assert!(matches!(angle.on_point(plane.y * 2.0), CmdResult::NeedPoint)); + let CmdResult::CommitEntity(entity) = angle.on_point(DVec3::new(7.0, 8.0, 9.0)) + else { + panic!("acquired angle must accept a through point"); + }; + assert!(direction(entity).abs_diff_eq(plane.y, 1.0e-12)); + } + + #[test] + fn xline_bisector_reuses_its_vertex_and_first_ray() { + let mut command = XLineCommand::new(); + assert!(matches!(command.on_text_input("B"), Some(CmdResult::NeedPoint))); + assert!(matches!(command.on_point(DVec3::ZERO), CmdResult::NeedPoint)); + assert!(matches!(command.on_point(DVec3::X), CmdResult::NeedPoint)); + + for end in [DVec3::Y, -DVec3::Y] { + let CmdResult::CommitEntity(entity) = command.on_point(end) else { + panic!("valid bisector must commit"); + }; + let expected = (DVec3::X + end).normalize(); + assert!(direction(entity).abs_diff_eq(expected, 1.0e-12)); + } + } + + #[test] + fn xline_offset_distance_returns_to_source_selection() { + let mut command = XLineCommand::new(); + assert!(matches!(command.on_text_input("O"), Some(CmdResult::NeedPoint))); + assert!(matches!(command.on_point(DVec3::ZERO), CmdResult::NeedPoint)); + assert!(matches!(command.on_point(DVec3::Y * 2.0), CmdResult::NeedPoint)); + assert!(command.needs_entity_pick()); + + let line = acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ); + command.inject_picked_entity(EntityType::Line(line)); + assert!(matches!( + command.on_entity_pick(acadrust::Handle::new(1), DVec3::ZERO), + CmdResult::NeedPoint + )); + let CmdResult::CommitEntity(EntityType::XLine(entity)) = + command.on_point(DVec3::Y) + else { + panic!("offset side must commit a construction line"); + }; + assert_eq!(entity.base_point, Vector3::new(0.0, 2.0, 0.0)); + assert_eq!(entity.direction, Vector3::new(1.0, 0.0, 0.0)); + assert!(command.needs_entity_pick()); + } } // ── Autocomplete registry ───────────────────────────────── diff --git a/src/modules/draw/draw/spline.rs b/src/modules/draw/draw/spline.rs index 8ace3cb2d..aecb9e0bb 100644 --- a/src/modules/draw/draw/spline.rs +++ b/src/modules/draw/draw/spline.rs @@ -5,7 +5,8 @@ use crate::t; use acadrust::types::Vector3; -use acadrust::{EntityType, Spline}; +use acadrust::{CadDocument, EntityType, Handle, Spline}; +use std::collections::HashMap; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -26,6 +27,16 @@ pub struct SplineCommand { pts: Vec, control_vertices: bool, choosing_method: bool, + choosing_knots: bool, + choosing_tangent: bool, + knot_parameterization: i32, + begin_tangent: Vector3, + end_tangent: Vector3, + choosing_objects: bool, + convertible: HashMap, + selected_objects: Vec, + choosing_degree: bool, + degree: usize, } impl SplineCommand { @@ -34,6 +45,16 @@ impl SplineCommand { pts: Vec::new(), control_vertices: false, choosing_method: false, + choosing_knots: false, + choosing_tangent: false, + knot_parameterization: 0, + begin_tangent: Vector3::ZERO, + end_tangent: Vector3::ZERO, + choosing_objects: false, + convertible: HashMap::new(), + selected_objects: Vec::new(), + choosing_degree: false, + degree: 3, } } @@ -44,75 +65,86 @@ impl SplineCommand { } } + pub fn with_document(mut self, document: &CadDocument) -> Self { + self.convertible = document.entities().filter_map(|entity| { + spline_from_polyline(entity).map(|spline| (entity.common().handle, spline)) + }).collect(); + self + } + fn build(&self, closed: bool) -> Option { if self.pts.len() < 2 { return None; } - Some(EntityType::Spline(make_spline( - &self.pts, - closed, - self.control_vertices, - ))) + let mut spline = if self.control_vertices { + let points: Vec<_> = self.pts.iter().map(|point| point.to_array()).collect(); + control_spline(&points, self.degree.min(points.len() - 1), closed)? + } else { + make_spline(&self.pts, closed) + }; + spline.knot_parameterization = self.knot_parameterization; + spline.begin_tangent = self.begin_tangent; + spline.end_tangent = self.end_tangent; + Some(EntityType::Spline(spline)) } } +fn control_spline(points: &[[f64; 3]], degree: usize, closed: bool) -> Option { + let curve = cadkernel::space::NurbsCurve3::from_control_polygon(degree, points, closed)?; + let mut spline = Spline { + degree: curve.degree() as i32, + control_points: curve.control_points().iter().map(|p| Vector3::new(p[0], p[1], p[2])).collect(), + knots: curve.knots().to_vec(), + weights: curve.weights().to_vec(), + ..Default::default() + }; + spline.flags.closed = closed; + spline.flags.periodic = closed; + spline.flags.planar = crate::entities::curve::spline_is_planar(&spline); + Some(spline) +} + +fn spline_from_polyline(entity: &EntityType) -> Option { + let (degree, points, closed, normal) = match entity { + EntityType::Polyline2D(poly) if poly.flags.is_spline_fit() => { + let degree = match poly.smooth_surface as i16 { 5 => 2, 6 => 3, _ => return None }; + let plane = crate::entities::curve::ocs_plane(poly.normal, poly.elevation); + let controls: Vec<_> = poly.vertices.iter().filter(|vertex| vertex.flags.bits() & 16 != 0) + .map(|vertex| plane.point_at([vertex.location.x, vertex.location.y])).collect(); + (degree, controls, poly.is_closed(), poly.normal) + } + EntityType::Polyline3D(poly) if poly.flags.spline_fit => { + let degree = match poly.smooth_type as i16 { 5 => 2, 6 => 3, _ => return None }; + let controls: Vec<_> = poly.vertices.iter().filter(|vertex| vertex.flags & 16 != 0) + .map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z]).collect(); + (degree, controls, poly.is_closed(), poly.normal) + } + _ => return None, + }; + let mut spline = control_spline(&points, degree, closed)?; + spline.normal = normal; + spline.common = entity.common().clone(); + spline.common.handle = Handle::NULL; + Some(spline) +} + /// Store the chosen construction method directly in the persistent spline. -fn make_spline(pts: &[DVec3], closed: bool, control_vertices: bool) -> Spline { - let mut points: Vec = pts.iter().map(|p| Vector3::new(p.x, p.y, p.z)).collect(); - let mut spline = if control_vertices { - if closed && pts.first() != pts.last() { - points.push(points[0]); - } - let count = points.len(); - let degree = 3.min(count.saturating_sub(1)); - let spans = count - degree; - // Open uniform knot vector: degree + 1 equal knots at each endpoint. - let knots = (0..count + degree + 1) - .map(|index| { - if index <= degree { - 0.0 - } else if index >= count { - 1.0 - } else { - (index - degree) as f64 / spans as f64 - } - }) - .collect(); - Spline { - degree: degree as i32, - control_points: points, - knots, - weights: vec![1.0; count], - ..Default::default() - } - } else { - Spline { - degree: 3, - fit_points: points, - ..Default::default() - } +fn make_spline(pts: &[DVec3], closed: bool) -> Spline { + let mut spline = Spline { + degree: 3, + fit_points: pts + .iter() + .map(|point| Vector3::new(point.x, point.y, point.z)) + .collect(), + ..Default::default() }; spline.flags.closed = closed; + // Closed fit curves use the kernel's periodic interpolation rather than + // appending a closing straight segment to an open interpolant. + spline.flags.periodic = closed; spline } -/// Preview the exact representation that `build()` commits. -fn sample_curve(pts: &[DVec3], closed: bool, control_vertices: bool) -> Vec<[f32; 3]> { - if pts.len() < 2 { - return pts - .iter() - .map(|p| [p.x as f32, p.y as f32, p.z as f32]) - .collect(); - } - let spline = make_spline(pts, closed, control_vertices); - crate::entities::curve::spline_curve(&spline) - .map(|curve| crate::entities::curve::curve_points(&curve)) - .unwrap_or_else(|| crate::entities::spline::measurement_polyline(&spline)) - .into_iter() - .map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]) - .collect() -} - impl CadCommand for SplineCommand { fn name(&self) -> &'static str { if self.control_vertices { @@ -123,8 +155,15 @@ impl CadCommand for SplineCommand { } fn prompt(&self) -> String { - if self.choosing_method { + if self.choosing_objects { return t!("Select spline-fit polylines:").to_string(); } + if self.choosing_degree { + format!("SPLINE Enter degree of spline <{}>:", self.degree) + } else if self.choosing_method { "SPLINE Choose creation method [Fit/Control vertices]:".into() + } else if self.choosing_knots { + "SPLINE Enter knot parameterization [Chord/Square root/Uniform]:".into() + } else if self.choosing_tangent { + "SPLINE Specify tangent direction:".into() } else if self.pts.is_empty() && self.control_vertices { t!("SPLINE Specify first control point:").into_owned() } else if self.pts.is_empty() { @@ -137,16 +176,30 @@ impl CadCommand for SplineCommand { fn options(&self) -> Vec { use crate::command::CmdOption; + if self.choosing_objects { return Vec::new(); } if self.choosing_method { return vec![ CmdOption::new("Fit", "FIT"), CmdOption::new("Control vertices", "CV"), ]; } - if self.pts.is_empty() { - return vec![CmdOption::new(t!("Method").as_ref(), "M")]; + if self.choosing_degree { return vec![]; } + if self.choosing_tangent { return vec![]; } + if self.choosing_knots { + return vec![CmdOption::new("Chord", "CH"), CmdOption::new("Square root", "S"), CmdOption::new("Uniform", "U")]; } - let mut opts = vec![CmdOption::new(t!("Close").as_ref(), "C")]; + if self.pts.is_empty() { + let mut options = vec![CmdOption::new(t!("Method").as_ref(), "M"), CmdOption::new("Object", "O")]; + if self.control_vertices { + options.push(CmdOption::new("Degree", "D")); + } else { + options.push(CmdOption::new("Knots", "K")); + } + return options; + } + let mut opts = vec![]; + if self.pts.len() >= 3 { opts.push(CmdOption::new(t!("Close").as_ref(), "C")); } + if !self.control_vertices { opts.push(CmdOption::new("Tangency", "T")); } // Undo only makes sense once a control point exists. opts.push(CmdOption::new(t!("Undo").as_ref(), "U")); opts.push(CmdOption::enter(t!("Done").as_ref())); @@ -154,7 +207,23 @@ impl CadCommand for SplineCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { - if self.choosing_method || !pt.is_finite() { + if self.choosing_objects || self.choosing_method || self.choosing_knots + || self.choosing_degree || !pt.is_finite() { + return CmdResult::NeedPoint; + } + if self.choosing_tangent { + let Some(anchor) = self.pts.last() else { return CmdResult::NeedPoint; }; + let Some(dir) = (pt - *anchor).try_normalize() else { return CmdResult::NeedPoint; }; + let tangent = Vector3::new(dir.x, dir.y, dir.z); + if self.pts.len() == 1 { self.begin_tangent = tangent; } + else { self.end_tangent = tangent; } + self.choosing_tangent = false; + if self.pts.len() > 1 { + return self.build(false).map_or(CmdResult::NeedPoint, CmdResult::CommitAndExit); + } + return CmdResult::NeedPoint; + } + if self.pts.last().is_some_and(|last| last.distance_squared(pt) < 1e-20) { return CmdResult::NeedPoint; } self.pts.push(pt); @@ -162,10 +231,21 @@ impl CadCommand for SplineCommand { } fn on_enter(&mut self) -> CmdResult { - if self.choosing_method { + if self.choosing_objects { + let replacements = self.selected_objects.iter().filter_map(|handle| { + self.convertible.get(handle).map(|spline| (*handle, vec![EntityType::Spline(spline.clone())])) + }).collect::>(); + return if replacements.is_empty() { CmdResult::Cancel } + else { CmdResult::ReplaceMany(replacements, Vec::new()) }; + } + + if self.choosing_method || self.choosing_knots || self.choosing_degree { + self.choosing_degree = false; self.choosing_method = false; + self.choosing_knots = false; return CmdResult::NeedPoint; } + if self.choosing_tangent { return CmdResult::NeedPoint; } match self.build(false) { Some(e) => CmdResult::CommitAndExit(e), None => CmdResult::Cancel, @@ -173,13 +253,29 @@ impl CadCommand for SplineCommand { } fn enter_accepts_default_start(&self) -> bool { - self.pts.is_empty() && !self.choosing_method + self.pts.is_empty() && !self.choosing_method && !self.choosing_objects + && !self.choosing_knots && !self.choosing_degree && !self.choosing_tangent + } + + fn is_selection_gathering(&self) -> bool { self.choosing_objects } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.selected_objects = handles.into_iter().filter(|handle| self.convertible.contains_key(handle)).collect(); + CmdResult::NeedPoint } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } + fn on_undo_step(&mut self) -> Option { + if self.pts.is_empty() { return None; } + self.pts.pop(); + self.end_tangent = Vector3::ZERO; + self.choosing_tangent = false; + if self.pts.is_empty() { self.begin_tangent = Vector3::ZERO; } + Some(CmdResult::NeedPoint) + } + fn wants_text_input(&self) -> bool { true } @@ -189,7 +285,39 @@ impl CadCommand for SplineCommand { } fn on_text_input(&mut self, text: &str) -> Option { + if self.choosing_tangent { return None; } + if self.choosing_degree { + self.degree = text.trim().parse::().ok().filter(|d| (1..=10).contains(d))?; + self.choosing_degree = false; + return Some(CmdResult::NeedPoint); + } + if self.choosing_knots { + self.knot_parameterization = match text.trim().to_ascii_uppercase().as_str() { + "CH" | "CHORD" => 0, + "S" | "SQUARE" | "SQUAREROOT" => 1, + "U" | "UNIFORM" => 2, + _ => return None, + }; + self.choosing_knots = false; + return Some(CmdResult::NeedPoint); + } match text.trim().to_uppercase().as_str() { + "D" | "DEGREE" if self.pts.is_empty() && self.control_vertices => { + self.choosing_degree = true; + Some(CmdResult::NeedPoint) + } + "K" | "KNOTS" if self.pts.is_empty() && !self.control_vertices => { + self.choosing_knots = true; + Some(CmdResult::NeedPoint) + } + "T" | "TANGENCY" if !self.pts.is_empty() && !self.control_vertices => { + self.choosing_tangent = true; + Some(CmdResult::NeedPoint) + } + "O" | "OBJECT" if self.pts.is_empty() => { + self.choosing_objects = true; + Some(CmdResult::NeedPoint) + } "M" | "METHOD" if self.pts.is_empty() => { self.choosing_method = true; Some(CmdResult::NeedPoint) @@ -204,28 +332,33 @@ impl CadCommand for SplineCommand { self.choosing_method = false; Some(CmdResult::NeedPoint) } - "C" | "CLOSE" => match self.build(true) { + "C" | "CLOSE" if self.pts.len() >= 3 => match self.build(true) { Some(e) => Some(CmdResult::CommitAndExit(e)), None => Some(CmdResult::NeedPoint), }, "U" | "UNDO" => { - self.pts.pop(); - Some(CmdResult::NeedPoint) + self.on_undo_step().or(Some(CmdResult::NeedPoint)) } _ => None, } } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if self.pts.is_empty() { + if self.pts.is_empty() || self.choosing_objects || self.choosing_method + || self.choosing_knots || self.choosing_degree || self.choosing_tangent { return None; } // Preview the committed construction method. - let mut ctrl = self.pts.clone(); - ctrl.push(pt); + self.pts.push(pt); + let entity = self.build(false); + self.pts.pop(); + let Some(EntityType::Spline(spline)) = entity else { return None; }; + let points = crate::entities::curve::spline_curve(&spline) + .map(|curve| crate::entities::curve::curve_points(&curve)) + .unwrap_or_else(|| crate::entities::spline::measurement_polyline(&spline)); Some(WireModel::solid( "rubber_band".into(), - sample_curve(&ctrl, false, self.control_vertices), + points.into_iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(), WireModel::CYAN, false, )) @@ -244,16 +377,126 @@ mod tests { DVec3::new(2.0, 2.0, 0.0), DVec3::new(3.0, 0.0, 0.0), ]; - let spline = make_spline(&points, false, true); + let controls: Vec<_> = points.iter().map(|point| point.to_array()).collect(); + let spline = control_spline(&controls, 3, false).expect("valid control polygon"); assert_eq!(spline.degree, 3); assert_eq!(spline.control_points.len(), 4); assert_eq!(spline.knots, vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]); assert_eq!(spline.weights, vec![1.0; 4]); - assert!(sample_curve(&points, false, true) - .iter() - .flatten() - .all(|value| value.is_finite())); + assert!(crate::entities::curve::spline_curve(&spline).is_some()); + } + + #[test] + fn fit_options_persist_parameterization_and_endpoint_tangents() { + let mut command = SplineCommand::new(); + assert!(matches!(command.on_text_input("K"), Some(CmdResult::NeedPoint))); + assert!(matches!(command.on_text_input("S"), Some(CmdResult::NeedPoint))); + assert!(matches!(command.on_point(DVec3::ZERO), CmdResult::NeedPoint)); + assert!(matches!(command.on_text_input("T"), Some(CmdResult::NeedPoint))); + assert!(matches!(command.on_point(DVec3::X), CmdResult::NeedPoint)); + assert!(matches!(command.on_point(DVec3::new(1.0, 1.0, 0.0)), CmdResult::NeedPoint)); + assert!(matches!(command.on_point(DVec3::new(2.0, 0.0, 0.0)), CmdResult::NeedPoint)); + assert!(matches!(command.on_text_input("T"), Some(CmdResult::NeedPoint))); + + let CmdResult::CommitAndExit(EntityType::Spline(spline)) = + command.on_point(DVec3::new(2.0, 1.0, 0.0)) + else { + panic!("end tangency must complete the fit spline"); + }; + assert_eq!(spline.knot_parameterization, 1); + assert_eq!(spline.begin_tangent, Vector3::new(1.0, 0.0, 0.0)); + assert_eq!(spline.end_tangent, Vector3::new(0.0, 1.0, 0.0)); + assert!(crate::entities::curve::spline_curve(&spline).is_some()); + } + + #[test] + fn control_degree_is_clamped_to_the_available_points() { + let mut command = SplineCommand::control_vertices(); + assert!(matches!(command.on_text_input("D"), Some(CmdResult::NeedPoint))); + assert!(matches!(command.on_text_input("10"), Some(CmdResult::NeedPoint))); + for point in [DVec3::ZERO, DVec3::X, DVec3::Y] { + assert!(matches!(command.on_point(point), CmdResult::NeedPoint)); + } + let CmdResult::CommitAndExit(EntityType::Spline(spline)) = command.on_enter() else { + panic!("valid control polygon must commit"); + }; + assert_eq!(spline.degree, 2); + assert!(!spline.flags.closed); + assert!(!spline.flags.periodic); + } + + #[test] + fn object_conversion_waits_for_enter_and_preserves_the_replacement() { + let handle = Handle::new(17); + let converted = control_spline( + &[[0.0, 0.0, 0.0], [1.0, 2.0, 0.0], [3.0, 0.0, 0.0]], + 2, + false, + ) + .expect("valid converted spline"); + let mut command = SplineCommand::new(); + command.convertible.insert(handle, converted.clone()); + assert!(matches!(command.on_text_input("O"), Some(CmdResult::NeedPoint))); + assert!(command.is_selection_gathering()); + assert!(matches!( + command.on_selection_complete(vec![handle]), + CmdResult::NeedPoint + )); + assert!(matches!( + command.on_enter(), + CmdResult::ReplaceMany(replacements, erased) + if erased.is_empty() + && replacements == vec![(handle, vec![EntityType::Spline(converted)])] + )); + } + + #[test] + fn closed_fitted_polyline_conversion_preserves_a_periodic_spline() { + use acadrust::entities::{ + Polyline2D, PolylineFlags, SmoothSurfaceType, Vertex2D, VertexFlags, + }; + + let mut polyline = Polyline2D::new(); + polyline.flags = PolylineFlags::SPLINE_FIT | PolylineFlags::CLOSED; + polyline.smooth_surface = SmoothSurfaceType::CubicBSpline; + polyline.common.layer = "DETAIL".into(); + polyline.vertices = [ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(2.0, 0.0, 0.0), + Vector3::new(2.0, 2.0, 0.0), + Vector3::new(0.0, 2.0, 0.0), + ] + .into_iter() + .map(|point| { + let mut vertex = Vertex2D::new(point); + vertex.flags = VertexFlags::SPLINE_CONTROL; + vertex + }) + .collect(); + + let spline = spline_from_polyline(&EntityType::Polyline2D(polyline)) + .expect("fitted polyline must convert"); + assert_eq!(spline.degree, 3); + assert!(spline.flags.closed); + assert!(spline.flags.periodic); + assert!(spline.flags.planar); + assert_eq!(spline.common.layer, "DETAIL"); + assert!(crate::entities::curve::spline_curve(&spline).is_some()); + } + + #[test] + fn duplicate_points_and_point_undo_keep_tangent_state_consistent() { + let mut command = SplineCommand::new(); + assert!(matches!(command.on_point(DVec3::ZERO), CmdResult::NeedPoint)); + assert!(matches!(command.on_point(DVec3::ZERO), CmdResult::NeedPoint)); + assert_eq!(command.pts.len(), 1); + assert!(matches!(command.on_text_input("T"), Some(CmdResult::NeedPoint))); + assert!(matches!(command.on_point(DVec3::X), CmdResult::NeedPoint)); + assert_ne!(command.begin_tangent, Vector3::ZERO); + assert!(command.on_undo_step().is_some()); + assert!(command.pts.is_empty()); + assert_eq!(command.begin_tangent, Vector3::ZERO); } } diff --git a/src/modules/draw/draw/wipeout.rs b/src/modules/draw/draw/wipeout.rs index 5d4a93f87..23c4e568e 100644 --- a/src/modules/draw/draw/wipeout.rs +++ b/src/modules/draw/draw/wipeout.rs @@ -30,6 +30,7 @@ pub struct WipeoutCommand { plane: WorkingPlane, selected_polyline: Option, frame_mode: i16, + picked_entity: Option, } #[derive(Clone, Copy, PartialEq)] @@ -50,6 +51,7 @@ impl WipeoutCommand { plane: WorkingPlane::default(), selected_polyline: None, frame_mode: frame_mode.clamp(0, 2), + picked_entity: None, } } @@ -61,6 +63,7 @@ impl WipeoutCommand { plane: WorkingPlane::default(), selected_polyline: None, frame_mode: 1, + picked_entity: None, } } @@ -73,6 +76,7 @@ impl WipeoutCommand { plane: WorkingPlane::default(), selected_polyline: None, frame_mode: 1, + picked_entity: None, } } @@ -116,13 +120,14 @@ impl CadCommand for WipeoutCommand { WipeoutMode::Draw if self.points.is_empty() => { t!("WIPEOUT Specify first point or [Frames/Polyline] :").into_owned() } + WipeoutMode::Draw if self.points.len() == 1 => { + t!("WIPEOUT Specify next point:").into_owned() + } + WipeoutMode::Draw if self.points.len() == 2 => { + t!("WIPEOUT Specify next point or [Undo]:").into_owned() + } WipeoutMode::Draw => { - let n = self.points.len(); - t!( - "WIPEOUT Specify next point or [Undo/Close] (%{n} points):", - n = n - ) - .into_owned() + t!("WIPEOUT Specify next point or [Close/Undo]:").into_owned() } WipeoutMode::Polyline => { t!("WIPEOUT Polyline Select a closed planar polyline:").into_owned() @@ -152,9 +157,13 @@ impl CadCommand for WipeoutCommand { CmdOption::new(t!("Polyline").as_ref(), "P"), ] } - WipeoutMode::Draw => vec![ + WipeoutMode::Draw if self.points.len() == 1 => Vec::new(), + WipeoutMode::Draw if self.points.len() == 2 => vec![ CmdOption::new(t!("Undo").as_ref(), "U"), + ], + WipeoutMode::Draw => vec![ CmdOption::new(t!("Close").as_ref(), "C"), + CmdOption::new(t!("Undo").as_ref(), "U"), ], WipeoutMode::Frames => vec![ CmdOption::new(t!("Off").as_ref(), "OFF"), @@ -215,6 +224,7 @@ impl CadCommand for WipeoutCommand { CmdResult::NeedPoint } WipeoutMode::Draw if self.points.len() >= 3 => self.finish_draw(), + WipeoutMode::Draw => CmdResult::NeedPoint, WipeoutMode::Frames => { CmdResult::Dispatch(format!("WIPEOUTFRAME {}", self.frame_mode)) } @@ -232,6 +242,15 @@ impl CadCommand for WipeoutCommand { } fn on_entity_pick(&mut self, handle: Handle, _point: DVec3) -> CmdResult { + let Some(entity)=self.picked_entity.take().filter(|e|e.common().handle==handle) else { + return CmdResult::NeedPoint; + }; + if !matches!(entity,EntityType::LwPolyline(_)|EntityType::Polyline2D(_)) { + return CmdResult::NeedPoint; + } + if wipeout_from_polyline(&entity).is_none() { + return CmdResult::Measurement("WIPEOUT: select a closed planar polyline made of zero-width straight segments.".into()); + } if handle.is_null() { CmdResult::NeedPoint } else { @@ -241,6 +260,9 @@ impl CadCommand for WipeoutCommand { } } + fn inject_before_entity_pick(&self)->bool {self.mode==WipeoutMode::Polyline} + fn inject_picked_entity(&mut self,entity:EntityType){self.picked_entity=Some(entity);} + fn wants_text_input(&self) -> bool { matches!( self.mode, @@ -267,7 +289,7 @@ impl CadCommand for WipeoutCommand { self.mode = WipeoutMode::Polyline; Some(CmdResult::NeedPoint) } - "U" | "UNDO" if !self.points.is_empty() => Some(self.undo_point()), + "U" | "UNDO" if self.points.len() >= 2 => Some(self.undo_point()), "C" | "CLOSE" if self.points.len() >= 3 => Some(self.finish_draw()), _ => None, }, @@ -289,7 +311,7 @@ impl CadCommand for WipeoutCommand { } fn on_undo_step(&mut self) -> Option { - if self.mode == WipeoutMode::Draw && !self.points.is_empty() { + if self.mode == WipeoutMode::Draw && self.points.len() >= 2 { Some(self.undo_point()) } else { None @@ -424,7 +446,9 @@ pub(crate) fn wipeout_from_polyline(entity: &EntityType) -> Option { match entity { EntityType::LwPolyline(polyline) => { - if polyline.vertices.iter().any(|vertex| vertex.bulge != 0.0) { + if polyline.constant_width != 0.0 || polyline.vertices.iter().any(|vertex| { + vertex.bulge != 0.0 || vertex.start_width != 0.0 || vertex.end_width != 0.0 + }) { return None; } let raw: Vec<[f64; 2]> = polyline @@ -438,7 +462,10 @@ pub(crate) fn wipeout_from_polyline(entity: &EntityType) -> Option { from_ocs(&raw, polyline.normal, polyline.elevation) } EntityType::Polyline2D(polyline) => { - if polyline.vertices.iter().any(|vertex| vertex.bulge != 0.0) { + if polyline.start_width != 0.0 || polyline.end_width != 0.0 + || polyline.vertices.iter().any(|vertex| { + vertex.bulge != 0.0 || vertex.start_width != 0.0 || vertex.end_width != 0.0 + }) { return None; } let raw: Vec<[f64; 2]> = polyline @@ -457,3 +484,32 @@ pub(crate) fn wipeout_from_polyline(entity: &EntityType) -> Option { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["WIPEOUT"] }); + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::LwPolyline; + + #[test] + fn command_undo_never_removes_the_first_point() { + let mut command = WipeoutCommand::new_polygonal(1); + assert!(matches!(command.on_point(DVec3::ZERO), CmdResult::NeedPoint)); + assert!(command.on_undo_step().is_none()); + + assert!(matches!(command.on_point(DVec3::X), CmdResult::NeedPoint)); + assert!(matches!(command.on_undo_step(), Some(CmdResult::NeedPoint))); + assert_eq!(command.points, vec![DVec3::ZERO]); + } + + #[test] + fn wide_polyline_is_not_accepted_as_a_wipeout_boundary() { + let mut polyline = LwPolyline::from_points(vec![ + Vector2::new(0.0, 0.0), + Vector2::new(2.0, 0.0), + Vector2::new(0.0, 2.0), + ]); + polyline.is_closed = true; + polyline.constant_width = 1.0; + assert!(wipeout_from_polyline(&EntityType::LwPolyline(polyline)).is_none()); + } +} diff --git a/src/modules/draw/mod.rs b/src/modules/draw/mod.rs index a9db09f43..23f8f5bb0 100644 --- a/src/modules/draw/mod.rs +++ b/src/modules/draw/mod.rs @@ -2,6 +2,7 @@ mod changelog; pub mod clipboard; +pub mod constrain; pub mod defaults; mod donate; pub mod draw; @@ -33,6 +34,11 @@ impl CadModule for DrawModule { }; use crate::modules::insert::{create_block, insert_block}; use clipboard::{copy_clip, cut, paste}; + use constrain::{ + angle_tool, center_point_tool, coincident_tool, colinear, concentric, distance_tool, equal, + equal_distance_tool, fixed, horizontal, midpoint_tool, normal, parallel, perpendicular, point_on_curve_tool, + symmetric, tangent, vertical, + }; use draw::{arc, circle, ellipse, hatch, line, polyline, shapes}; use groups::{group, ungroup}; use layers::{ @@ -120,6 +126,29 @@ impl CadModule for DrawModule { offset::tool().into(), ], }, + RibbonGroup { + title: "Constraints", + tools: vec![ + horizontal::tool().into(), + vertical::tool().into(), + parallel::tool().into(), + perpendicular::tool().into(), + equal::tool().into(), + tangent::tool().into(), + normal::tool().into(), + coincident_tool::tool().into(), + distance_tool::tool().into(), + angle_tool::tool().into(), + concentric::tool().into(), + colinear::tool().into(), + fixed::tool().into(), + symmetric::tool().into(), + center_point_tool::tool().into(), + midpoint_tool::tool().into(), + point_on_curve_tool::tool().into(), + equal_distance_tool::tool().into(), + ], + }, RibbonGroup { title: "Annotation", tools: vec![ diff --git a/src/modules/draw/modify/align.rs b/src/modules/draw/modify/align.rs index 8cd4109c3..118dc8a87 100644 --- a/src/modules/draw/modify/align.rs +++ b/src/modules/draw/modify/align.rs @@ -1,13 +1,14 @@ -// ALIGN command — align selected objects using 1 or 2 point pairs. +// ALIGN command — rigid 3D placement using one, two, or three point pairs. // // Workflow: // 1. Select objects (Enter to finish selection) // 2. First source point → first destination point // 3. Second source point → second destination point (Enter to skip = translate only) -// 4. Enter = apply (scale = optional: Y/N prompt after 2nd pair) +// 4. Continue for a third pair, or press Enter to choose two-pair scaling // // With 1 pair: pure translation (src1 → dst1) // With 2 pairs: translate + rotate (+ optional uniform scale to fit) +// With 3 pairs: rigid 3D placement without scaling use acadrust::Handle; use glam::DVec3; @@ -23,6 +24,8 @@ pub struct AlignCommand { dst1: Option, src2: Option, dst2: Option, + src3: Option, + dst3: Option, } #[derive(PartialEq)] @@ -32,6 +35,8 @@ enum AlignState { Dst1, Src2, Dst2, + Src3, + Dst3, AskScale, } @@ -50,6 +55,8 @@ impl AlignCommand { dst1: None, src2: None, dst2: None, + src3: None, + dst3: None, } } } @@ -72,9 +79,11 @@ impl CadCommand for AlignCommand { t!("ALIGN Specify 2nd source point (Enter = translate only):").into_owned() } AlignState::Dst2 => t!("ALIGN Specify 2nd destination point:").into_owned(), + AlignState::Src3 => "ALIGN Specify 3rd source point or :".into(), + AlignState::Dst3 => "ALIGN Specify 3rd destination point:".into(), AlignState::AskScale => { t!( - "ALIGN Scale objects based on alignment points? [Yes / No]:" + "ALIGN Scale objects based on alignment points? [Yes / No] :" ) .into_owned() } @@ -103,6 +112,7 @@ impl CadCommand for AlignCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + if !pt.is_finite() { return CmdResult::NeedPoint; } match self.state { AlignState::Gathering => CmdResult::NeedPoint, AlignState::Src1 => { @@ -116,15 +126,36 @@ impl CadCommand for AlignCommand { CmdResult::NeedPoint } AlignState::Src2 => { + let pair = [self.src1.unwrap().to_array(), pt.to_array()]; + if cadkernel::space::align_point_pairs(&pair, &pair, false).is_none() { + return CmdResult::NeedPoint; + } self.src2 = Some(pt); self.state = AlignState::Dst2; CmdResult::NeedPoint } AlignState::Dst2 => { + let pair = [self.dst1.unwrap().to_array(), pt.to_array()]; + if cadkernel::space::align_point_pairs(&pair, &pair, false).is_none() { + return CmdResult::NeedPoint; + } self.dst2 = Some(pt); - self.state = AlignState::AskScale; + self.state = AlignState::Src3; CmdResult::NeedPoint } + AlignState::Src3 => { + let frame = [self.src1.unwrap().to_array(), self.src2.unwrap().to_array(), pt.to_array()]; + if cadkernel::space::align_point_pairs(&frame, &frame, false).is_none() { + return CmdResult::NeedPoint; + } + self.src3 = Some(pt); + self.state = AlignState::Dst3; + CmdResult::NeedPoint + } + AlignState::Dst3 => { + self.dst3 = Some(pt); + self.compute_align(false) + } AlignState::AskScale => CmdResult::NeedPoint, } } @@ -156,6 +187,10 @@ impl CadCommand for AlignCommand { } // Default option shown as . + AlignState::Src3 => { + self.state = AlignState::AskScale; + CmdResult::NeedPoint + } AlignState::AskScale => self.compute_align(false), _ => CmdResult::Cancel, @@ -204,6 +239,8 @@ impl CadCommand for AlignCommand { match self.state { AlignState::Src2 | AlignState::Dst2 + | AlignState::Src3 + | AlignState::Dst3 | AlignState::AskScale => { out.push(line(src1, dst1, "align_pair_1")); } @@ -230,10 +267,15 @@ impl CadCommand for AlignCommand { // Once both pairs are complete, keep both visible while // waiting for the Scale / No Scale decision. - AlignState::AskScale => { + AlignState::Src3 | AlignState::Dst3 | AlignState::AskScale => { if let (Some(src2), Some(dst2)) = (self.src2, self.dst2) { out.push(line(src2, dst2, "align_pair_2")); } + if self.state == AlignState::Dst3 { + if let Some(src3) = self.src3 { + out.push(line(src3, pt, "align_pair_3_preview")); + } + } } _ => {} @@ -245,50 +287,67 @@ impl CadCommand for AlignCommand { impl AlignCommand { fn compute_align(&self, with_scale: bool) -> CmdResult { - let (s1, d1, s2, d2) = match (self.src1, self.dst1, self.src2, self.dst2) { - (Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d), - _ => return CmdResult::Cancel, - }; - - // Build transform: move s1→d1, rotate so s2-s1 aligns with d2-d1 (in the world XY plane) - let src_vec = s2 - s1; - let dst_vec = d2 - d1; - - let src_len = src_vec.length(); - let dst_len = dst_vec.length(); - - if src_len < 1e-6 || dst_len < 1e-6 { - // Degenerate: just translate - let delta = d1 - s1; - return CmdResult::TransformSelected( - self.handles.clone(), - EntityTransform::Translate(delta), - ); - } - - // Angle from src_vec to dst_vec in the world XY plane - let src_angle = src_vec.y.atan2(src_vec.x); - let dst_angle = dst_vec.y.atan2(dst_vec.x); - let angle = dst_angle - src_angle; - - let scale_factor = if with_scale { dst_len / src_len } else { 1.0 }; - - // Apply: translate to origin (s1), scale, rotate, translate to d1 - // We use the EntityTransform enum — it doesn't support composed transforms directly. - // Return a special align result that carries the full matrix. - let _ = (angle, scale_factor, with_scale); - - // Compose via AlignTransform CmdResult - CmdResult::AlignSelected { - handles: self.handles.clone(), - src1: s1, - dst1: d1, - angle_rad: angle, - scale: scale_factor, + let (Some(s1), Some(d1), Some(s2), Some(d2)) = (self.src1, self.dst1, self.src2, self.dst2) + else { return CmdResult::NeedPoint; }; + let mut source = vec![s1.to_array(), s2.to_array()]; + let mut target = vec![d1.to_array(), d2.to_array()]; + if let (Some(s3), Some(d3)) = (self.src3, self.dst3) { + source.push(s3.to_array()); + target.push(d3.to_array()); } + let Some(matrix) = cadkernel::space::align_point_pairs(&source, &target, with_scale) + else { return CmdResult::NeedPoint; }; + CmdResult::TransformSelected(self.handles.clone(), EntityTransform::Affine( + acadrust::types::Transform::from_matrix(acadrust::types::Matrix4 { m: matrix }), + )) } } - -// ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["ALIGN"] }); // AlignCommand + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn three_pairs_apply_a_rigid_spatial_frame() { + let handle = Handle::new(9); + let mut command = AlignCommand::with_selection(vec![handle]); + let source = [DVec3::ZERO, DVec3::X, DVec3::Y]; + let origin = DVec3::new(5.0, 6.0, 7.0); + let target = [origin, origin + DVec3::Y, origin + DVec3::Z]; + + assert!(matches!(command.on_point(DVec3::splat(f64::NAN)), CmdResult::NeedPoint)); + for point in [source[0], target[0], source[1], target[1]] { + assert!(matches!(command.on_point(point), CmdResult::NeedPoint)); + } + assert!(matches!(command.on_point(DVec3::X * 2.0), CmdResult::NeedPoint)); + assert!(matches!(command.state, AlignState::Src3)); + assert!(matches!(command.on_point(source[2]), CmdResult::NeedPoint)); + + let CmdResult::TransformSelected(handles, EntityTransform::Affine(transform)) = + command.on_point(target[2]) + else { + panic!("third destination must complete alignment"); + }; + assert_eq!(handles, vec![handle]); + for (from, expected) in source.into_iter().zip(target) { + let actual = transform.apply(acadrust::types::Vector3::new(from.x, from.y, from.z)); + let actual = DVec3::new(actual.x, actual.y, actual.z); + assert!(actual.abs_diff_eq(expected, 1.0e-12)); + } + } + + #[test] + fn one_pair_still_translates_without_requesting_a_frame() { + let handle = Handle::new(3); + let mut command = AlignCommand::with_selection(vec![handle]); + assert!(matches!(command.on_point(DVec3::new(1.0, 2.0, 3.0)), CmdResult::NeedPoint)); + assert!(matches!(command.on_point(DVec3::new(4.0, 6.0, 8.0)), CmdResult::NeedPoint)); + assert!(matches!( + command.on_enter(), + CmdResult::TransformSelected(handles, EntityTransform::Translate(delta)) + if handles == vec![handle] && delta == DVec3::new(3.0, 4.0, 5.0) + )); + } +} diff --git a/src/modules/draw/modify/break_cmd.rs b/src/modules/draw/modify/break_cmd.rs index 2ce6e8460..5f00bdccd 100644 --- a/src/modules/draw/modify/break_cmd.rs +++ b/src/modules/draw/modify/break_cmd.rs @@ -382,7 +382,7 @@ impl CadCommand for BreakInteractiveCommand { } else if self.p1.is_none() { crate::t!("BREAK Specify first break point:").into_owned() } else { - crate::t!("BREAK Specify second break point:").into_owned() + crate::t!("BREAK Specify second break point or [First point]:").into_owned() } } @@ -390,6 +390,28 @@ impl CadCommand for BreakInteractiveCommand { self.target.is_none() } + fn options(&self) -> Vec { + if self.target.is_some() && self.p1.is_some() { + vec![crate::command::CmdOption::new("First point", "F")] + } else { + Vec::new() + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let handle = self.target?; + match text.trim().to_ascii_uppercase().as_str() { + "F" | "FIRST" => { + self.p1 = None; + Some(CmdResult::NeedPoint) + } + "@" => self.p1.map(|point| CmdResult::BreakEntity { + handle, p1: point, p2: point, + }), + _ => None, + } + } + fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; @@ -480,3 +502,28 @@ impl CadCommand for BreakAtPointCommand { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["BREAKATPOINT"] }); // BreakAtPointCommand inventory::submit!(crate::command::CommandRegistration { names: &["BREAK"] }); // BreakInteractiveCommand + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_option_replaces_the_selection_point_and_at_reuses_it() { + let handle = Handle::new(7); + let mut command = BreakInteractiveCommand::new(); + assert!(matches!( + command.on_entity_pick(handle, DVec3::new(1.0, 2.0, 0.0)), + CmdResult::NeedPoint + )); + assert_eq!(command.options().len(), 1); + assert!(matches!(command.on_text_input("F"), Some(CmdResult::NeedPoint))); + + let replacement = DVec3::new(3.0, 4.0, 0.0); + assert!(matches!(command.on_point(replacement), CmdResult::NeedPoint)); + assert!(matches!( + command.on_text_input("@"), + Some(CmdResult::BreakEntity { handle: result, p1, p2 }) + if result == handle && p1 == replacement && p2 == replacement + )); + } +} diff --git a/src/modules/draw/modify/join.rs b/src/modules/draw/modify/join.rs index 9a101dea7..215f90db3 100644 --- a/src/modules/draw/modify/join.rs +++ b/src/modules/draw/modify/join.rs @@ -12,62 +12,152 @@ use acadrust::types::{Vector2, Vector3}; use acadrust::{EntityType, Handle}; use glam::DVec3; -use crate::t; use crate::command::{CadCommand, CmdResult}; // ── Command ──────────────────────────────────────────────────────────────── pub struct JoinCommand { + source: Option<(Handle, EntityType)>, + picked: Option, handles: Vec, - gathering: bool, } - impl JoinCommand { - pub fn new() -> Self { - Self { - handles: vec![], - gathering: true, - } + pub fn new() -> Self { Self { source: None, picked: None, handles: Vec::new() } } + pub fn with_source(mut self, handle: Handle, entity: EntityType) -> Self { + self.picked = Some(entity); self.on_entity_pick(handle, DVec3::ZERO); self } + fn has_arc_source(&self) -> bool { matches!(&self.source, Some((_, EntityType::Arc(_)))) } } - impl CadCommand for JoinCommand { - fn name(&self) -> &'static str { - "JOIN" - } - + fn name(&self) -> &'static str { "JOIN" } fn prompt(&self) -> String { - t!( - "JOIN Select objects to join (%{count} selected, Enter to apply):", - count = self.handles.len() - ) - .into_owned() - } - - fn is_selection_gathering(&self) -> bool { - self.gathering - } - - fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { - self.handles = handles; - CmdResult::NeedPoint - } - - fn on_point(&mut self, _pt: DVec3) -> CmdResult { - CmdResult::NeedPoint - } - - fn on_enter(&mut self) -> CmdResult { - if self.handles.len() < 2 { - return CmdResult::Cancel; + match self.source.as_ref().map(|(_, entity)| entity) { + None => "JOIN Select source object:".into(), + Some(EntityType::Line(_)) => "JOIN Select lines to join to source (Enter to apply):".into(), + Some(EntityType::Arc(_)) => "JOIN Select arcs to join to source or [cLose] (Enter to apply):".into(), + Some(EntityType::Spline(_)) => "JOIN Select open curves to join to source (Enter to apply):".into(), + _ => "JOIN Select objects to join to source (Enter to apply):".into(), } - self.gathering = false; - CmdResult::JoinEntities(self.handles.clone()) + } + fn options(&self) -> Vec { + if matches!(&self.source,Some((_,EntityType::Arc(_)))) { vec![crate::command::CmdOption::new("Close","L")] } else {Vec::new()} + } + fn wants_text_input(&self) -> bool { self.has_arc_source() } + fn point_step_accepts_keywords(&self) -> bool { self.has_arc_source() } + fn on_text_input(&mut self,text:&str) -> Option { + if matches!(text.trim().to_ascii_uppercase().as_str(),"L"|"CLOSE") { + if let Some((handle,EntityType::Arc(arc)))=&self.source { + let mut circle=acadrust::entities::Circle::new(); + circle.common=arc.common.clone();circle.common.handle=Handle::NULL; + circle.center=arc.center.clone();circle.normal=arc.normal.clone();circle.radius=arc.radius;circle.thickness=arc.thickness; + return Some(CmdResult::ReplaceMany(vec![(*handle,vec![EntityType::Circle(circle)])],Vec::new())); + } + } + None + } + fn needs_entity_pick(&self) -> bool { self.source.is_none() } + fn inject_before_entity_pick(&self) -> bool { true } + fn inject_picked_entity(&mut self, entity: EntityType) { self.picked = Some(entity); } + fn on_entity_pick(&mut self, handle: Handle, _: DVec3) -> CmdResult { + if handle.is_null() { return CmdResult::NeedPoint; } + if let Some(entity) = self.picked.take() { + let supported = match &entity { + EntityType::Line(_) | EntityType::Arc(_) => true, + EntityType::LwPolyline(p) => !p.is_closed, + EntityType::Polyline2D(p) => !p.is_closed(), + EntityType::Spline(p) => !p.flags.closed && !p.flags.periodic, + _ => false, + }; + if supported { self.source = Some((handle, entity)); } + } + CmdResult::NeedPoint + } + fn is_selection_gathering(&self) -> bool { self.source.is_some() } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.handles = handles.into_iter().filter(|handle| self.source.as_ref().is_none_or(|(source,_)| handle != source)).collect(); CmdResult::NeedPoint + } + fn on_point(&mut self, _: DVec3) -> CmdResult { CmdResult::NeedPoint } + fn on_enter(&mut self) -> CmdResult { + let Some((source, _)) = self.source.as_ref() else { return CmdResult::Cancel; }; + if self.handles.is_empty() { return CmdResult::Cancel; } + CmdResult::JoinToSource { source: *source, handles: self.handles.clone() } } } -// ── Geometry ─────────────────────────────────────────────────────────────── +/// Join compatible candidates while preserving the source identity and style. +pub fn join_to_source(source: &EntityType, candidates: &[(Handle, &EntityType)]) -> Option<(EntityType, Vec)> { + let mut result = source.clone(); let mut consumed = Vec::new(); + loop { + let mut progress = false; + for (handle, candidate) in candidates { + if consumed.contains(handle) { continue; } + let next = match (&result, *candidate) { + (EntityType::Line(a), EntityType::Line(b)) => { + let point = |p: &Vector3| [p.x,p.y,p.z]; + cadkernel::space::source_join::join_collinear_lines([point(&a.start),point(&a.end)],[point(&b.start),point(&b.end)],JOIN_EPS).map(|span| { + let mut line = a.clone(); line.start = Vector3::new(span[0][0],span[0][1],span[0][2]); line.end = Vector3::new(span[1][0],span[1][1],span[1][2]); EntityType::Line(line) + }) + } + (EntityType::Arc(a), EntityType::Arc(b)) => { + let data = |a: &acadrust::entities::Arc| ([a.center.x,a.center.y,a.center.z],[a.normal.x,a.normal.y,a.normal.z],a.radius,[a.start_angle,a.end_angle]); + cadkernel::space::source_join::join_cocircular_arcs(data(a),data(b),JOIN_EPS).map(|span| { + if span[1]-span[0] >= std::f64::consts::TAU { + let mut circle = acadrust::entities::Circle::new(); circle.common = a.common.clone(); circle.center = a.center.clone(); circle.normal = a.normal.clone(); circle.radius = a.radius; circle.thickness = a.thickness; EntityType::Circle(circle) + } else { let mut arc = a.clone(); arc.start_angle = span[0]; arc.end_angle = span[1].rem_euclid(std::f64::consts::TAU); EntityType::Arc(arc) } + }) + } + (EntityType::Spline(source), candidate) => { + let curve = |s:&acadrust::entities::Spline| { + if s.flags.closed || s.flags.periodic {return None;} + if s.control_points.is_empty() && s.fit_points.len() >= 2 { + use cadkernel::space::{NurbsCurve3, Parameterization}; + let points: Vec<_> = s.fit_points.iter().map(|point| [point.x,point.y,point.z]).collect(); + let parameterization = match s.knot_parameterization { + 1 => Parameterization::Centripetal, 2 => Parameterization::Uniform, _ => Parameterization::Chord, + }; + let tangent = |point: &Vector3| (point.x != 0.0 || point.y != 0.0 || point.z != 0.0) + .then_some([point.x,point.y,point.z]); + return NurbsCurve3::interpolate_fit(&points,tangent(&s.begin_tangent),tangent(&s.end_tangent),parameterization)? + .compact_knots(s.control_tolerance.max(1e-9)); + } + let weights=if s.weights.is_empty(){vec![1.0;s.control_points.len()]}else{s.weights.clone()}; + cadkernel::space::NurbsCurve3::new_strict(s.degree as usize,s.control_points.iter().map(|p|[p.x,p.y,p.z]).collect(),s.knots.clone(),weights) + }; + curve(source).and_then(|a| { + let b=match candidate { + EntityType::Spline(s)=>curve(s), + EntityType::Line(line)=>cadkernel::space::source_join::line_as_nurbs([[line.start.x,line.start.y,line.start.z],[line.end.x,line.end.y,line.end.z]],a.degree()), + _=>None, + }?; + let joined=cadkernel::space::source_join::join_nurbs_curves(&a,&b,JOIN_EPS)?; + let mut spline=source.clone(); + spline.degree=joined.degree() as i32; + spline.control_points=joined.control_points().iter().map(|p|Vector3::new(p[0],p[1],p[2])).collect(); + spline.knots=joined.knots().to_vec();spline.weights=joined.weights().to_vec(); + spline.fit_points.clear();spline.flags.rational=joined.is_rational(); + spline.dwg_flags1 &= !1;spline.dxf_flags &= !(32 | 1024); + spline.flags.planar=cadkernel::space::are_coplanar(joined.control_points(),&[]); + spline.begin_tangent=Vector3::new(0.0,0.0,0.0);spline.end_tangent=Vector3::new(0.0,0.0,0.0); + Some(EntityType::Spline(spline)) + }) + } + (EntityType::LwPolyline(_) | EntityType::Polyline2D(_), _) => { + join_entities(&[(result.common().handle,&result),(*handle,*candidate)]).and_then(|(_,mut entities)| { + let entity = entities.pop()?; + if matches!(&entity,EntityType::Line(_)) { super::pedit::convert_to_polyline(&entity) } else { Some(entity) } + }) + } + _ => None, + }; + if let Some(mut entity) = next { + *entity.common_mut() = source.common().clone(); result = entity; consumed.push(*handle); progress = true; + } + } + if !progress { break; } + } + (!consumed.is_empty()).then_some((result,consumed)) +} /// Endpoint-match tolerance (model units). Segments split from a shared /// vertex meet exactly, so this only absorbs float noise. @@ -81,12 +171,14 @@ struct Seg { a: DVec3, b: DVec3, bulge: f64, + widths: Option<(f64, f64)>, } impl Seg { fn flip(&mut self) { std::mem::swap(&mut self.a, &mut self.b); self.bulge = -self.bulge; + self.widths = self.widths.map(|(start,end)| (end,start)); } } @@ -124,6 +216,7 @@ fn segs_of(e: &EntityType) -> Option> { a: DVec3::new(l.start.x, l.start.y, l.start.z), b: DVec3::new(l.end.x, l.end.y, l.end.z), bulge: 0.0, + widths: None, }]), EntityType::Arc(arc) => { // The bulge below assumes the arc lies in a +Z plane; a tilted @@ -139,6 +232,7 @@ fn segs_of(e: &EntityType) -> Option> { a: DVec3::new(cx + r * sa.cos(), cy + r * sa.sin(), cz), b: DVec3::new(cx + r * ea.cos(), cy + r * ea.sin(), cz), bulge: (swept / 4.0).tan(), + widths: None, }]) } EntityType::LwPolyline(p) => { @@ -154,6 +248,7 @@ fn segs_of(e: &EntityType) -> Option> { a: DVec3::new(w[0].location.x, w[0].location.y, z), b: DVec3::new(w[1].location.x, w[1].location.y, z), bulge: w[0].bulge, + widths: Some(if p.constant_width != 0.0 { (p.constant_width,p.constant_width) } else { (w[0].start_width,w[0].end_width) }), }) .collect(), ) @@ -176,10 +271,12 @@ fn segs_of(e: &EntityType) -> Option> { curve .vertices .windows(2) - .map(|w| Seg { + .enumerate() + .map(|(index,w)| Seg { a: DVec3::new(w[0].position[0], w[0].position[1], z), b: DVec3::new(w[1].position[0], w[1].position[1], z), bulge: w[0].bulge, + widths: Some((if p.vertices[index].start_width == 0.0 {p.start_width} else {p.vertices[index].start_width}, if p.vertices[index].end_width == 0.0 {p.end_width} else {p.vertices[index].end_width})), }) .collect(), ) @@ -208,7 +305,13 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec, let common = entities[0].1.common().clone(); let (thickness, normal) = extrusion(entities[0].1)?; - let (chain, closed) = stitch(segs)?; + let (mut chain, closed) = stitch(segs)?; + // Newly appended line/arc spans inherit the adjoining polyline width. + let mut width = chain.iter().find_map(|segment| segment.widths.map(|pair| pair.0)).unwrap_or(0.0); + for segment in &mut chain { + if let Some((_,end)) = segment.widths { width = end; } + else { segment.widths = Some((width,width)); } + } // Ordered vertices, each tagged with the bulge of the segment that // starts there. A closed chain reuses the first vertex as the wrap @@ -223,7 +326,7 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec, let planar = verts.iter().all(|(p, _)| (p.z - z0).abs() <= JOIN_EPS); // An open run of collinear straight segments collapses back to one Line. - if !closed && !has_arc && is_collinear(&verts) { + if !closed && !has_arc && chain.iter().all(|segment| segment.widths == Some((0.0,0.0))) && !matches!(entities[0].1, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)) && is_collinear(&verts) { let mut line = acadrust::entities::Line::new(); line.common = common; line.common.handle = Handle::NULL; @@ -240,13 +343,16 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec, } let flipped = thickness != 0.0 && normal.z < 0.0; let lw_verts: Vec = verts - .iter() - .map(|(p, bulge)| { + .iter().enumerate() + .map(|(index,(p, bulge))| { let mut v = acadrust::entities::LwVertex::new(Vector2::new( if flipped { -p.x } else { p.x }, p.y, )); v.bulge = if flipped { -*bulge } else { *bulge }; + let (start,end) = chain.get(index).and_then(|segment| segment.widths) + .unwrap_or_else(|| {let width=chain.last().and_then(|segment| segment.widths).map_or(0.0,|pair|pair.1);(width,width)}); + v.start_width = start; v.end_width = end; v }) .collect(); @@ -260,6 +366,26 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec, if flipped { pl.normal = Vector3::new(0.0, 0.0, -1.0); } + if let EntityType::Polyline2D(source) = entities[0].1 { + let mut polyline = source.clone(); + let input = crate::entities::curve::ocs_plane(pl.normal.clone(),pl.elevation); + let output = crate::entities::curve::ocs_plane(source.normal.clone(),source.elevation); + polyline.vertices = pl.vertices.iter().map(|vertex| { + let point = output.project(input.point_at([vertex.location.x,vertex.location.y]))?; + let mut result = acadrust::entities::polyline::Vertex2D::new(Vector3::new(point[0],point[1],source.elevation)); + result.bulge = if cadkernel::space::Vec3::from(input.normal()?).dot(cadkernel::space::Vec3::from(output.normal()?)) < 0.0 {-vertex.bulge} else {vertex.bulge}; result.start_width = vertex.start_width; result.end_width = vertex.end_width; + Some(result) + }).collect::>>()?; + polyline.start_width = 0.0; polyline.end_width = 0.0; + polyline.flags.set_closed(pl.is_closed); + return Some((handles,vec![EntityType::Polyline2D(polyline)])); + } + if let EntityType::LwPolyline(source) = entities[0].1 { + pl.plinegen = source.plinegen; + if source.constant_width != 0.0 && pl.vertices.iter().all(|vertex| vertex.start_width == source.constant_width && vertex.end_width == source.constant_width) { + pl.constant_width = source.constant_width; + } + } return Some((handles, vec![EntityType::LwPolyline(pl)])); } @@ -399,6 +525,94 @@ mod join_tests { l.thickness ); } + + #[test] + fn source_join_consumes_only_compatible_lines_and_keeps_direction() { + let source_handle = Handle::new(11); + let rejected_handle = Handle::new(12); + let joined_handle = Handle::new(13); + let mut source = line(5.0, 0.0, 1.0, 0.0, 2.0); + source.common_mut().handle = source_handle; + let rejected = line(1.0, 0.0, 1.0, 2.0, 0.0); + let joined = line(8.0, 0.0, 6.0, 0.0, 0.0); + let (result, consumed) = join_to_source( + &source, + &[(rejected_handle, &rejected), (joined_handle, &joined)], + ) + .unwrap(); + assert_eq!(consumed, vec![joined_handle]); + let EntityType::Line(result) = result else { + panic!("expected a line"); + }; + assert_eq!(result.common.handle, source_handle); + assert_eq!(result.start, Vector3::new(8.0, 0.0, 0.0)); + assert_eq!(result.end, Vector3::new(1.0, 0.0, 0.0)); + assert_eq!(result.thickness, 2.0); + } + + #[test] + fn source_join_elevates_splines_and_clears_fit_representation() { + let source_handle = Handle::new(21); + let candidate_handle = Handle::new(22); + let mut source = acadrust::entities::Spline::new(); + source.common.handle = source_handle; + source.degree = 1; + source.control_points = vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ]; + source.knots = vec![0.0, 0.0, 1.0, 1.0]; + source.fit_points = vec![Vector3::new(0.0, 0.0, 0.0)]; + source.dwg_flags1 = 1; + source.dxf_flags = 32 | 1024; + let mut candidate = acadrust::entities::Spline::new(); + candidate.degree = 3; + candidate.control_points = vec![ + Vector3::new(1.0, 0.0, 0.0), + Vector3::new(1.3, 0.2, 0.0), + Vector3::new(1.7, 0.8, 0.0), + Vector3::new(2.0, 1.0, 0.0), + ]; + candidate.knots = vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]; + let candidate_entity = EntityType::Spline(candidate); + let (result, consumed) = join_to_source( + &EntityType::Spline(source), + &[(candidate_handle, &candidate_entity)], + ) + .unwrap(); + assert_eq!(consumed, vec![candidate_handle]); + let EntityType::Spline(result) = result else { + panic!("expected a spline"); + }; + assert_eq!(result.common.handle, source_handle); + assert_eq!(result.degree, 3); + assert!(result.fit_points.is_empty()); + assert_eq!(result.dwg_flags1 & 1, 0); + assert_eq!(result.dxf_flags & (32 | 1024), 0); + assert!(result.flags.planar); + } + + #[test] + fn arc_source_accepts_the_typed_close_option() { + let handle = Handle::new(31); + let mut arc = acadrust::entities::Arc::new(); + arc.common.handle = handle; + arc.center = Vector3::new(2.0, 3.0, 4.0); + arc.radius = 5.0; + arc.thickness = 0.75; + let mut command = JoinCommand::new().with_source(handle, EntityType::Arc(arc)); + assert!(command.input_kind().wants_text()); + assert!(command.point_step_accepts_keywords()); + let Some(CmdResult::ReplaceMany(mut replacements, _)) = command.on_text_input("Close") else { + panic!("expected the arc to close"); + }; + let Some(EntityType::Circle(circle)) = replacements[0].1.pop() else { + panic!("expected a circle"); + }; + assert_eq!(circle.center, Vector3::new(2.0, 3.0, 4.0)); + assert_eq!(circle.radius, 5.0); + assert_eq!(circle.thickness, 0.75); + } } /// True when every vertex lies on one straight line (within tolerance). diff --git a/src/modules/draw/modify/lengthen.rs b/src/modules/draw/modify/lengthen.rs index 6c241cf64..eb58760a5 100644 --- a/src/modules/draw/modify/lengthen.rs +++ b/src/modules/draw/modify/lengthen.rs @@ -1,6 +1,6 @@ // LENGTHEN command — extend or trim a Line or Arc by a specified delta or total. // -// Options (entered as text after the entity pick): +// Choose an option, enter its value, then pick objects repeatedly: // DE — extend by delta (positive extends, negative trims) // TO — set total length (Line) or arc length (Arc) // P — change by percentage (100 = no change, 150 = +50%) @@ -9,7 +9,7 @@ use crate::modules::draw::modify::spline_ops::{spline_cut, spline_to_nurbs}; use acadrust::entities::{ - Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, Spline as SplineEnt, + Ellipse as EllipseEnt, LwPolyline, Spline as SplineEnt, }; use cadkernel::geom2d::{ Curve, Ellipse as KernelEllipse, EllipseArc as KernelEllipseArc, @@ -25,120 +25,191 @@ const TAU: f64 = std::f64::consts::TAU; pub struct LengthenCommand { state: LenState, + picked: Option, + measurement: Option, + edits: usize, } +#[derive(Clone, Copy)] +enum ValueMode { Delta, Total, Percent, DeltaAngle, TotalAngle, Dynamic } + +struct LengthenDefaults { mode: ValueMode, delta: f64, total: f64, percent: f64, delta_angle: f64, total_angle: f64 } +static LENGTHEN_DEFAULTS: std::sync::Mutex = std::sync::Mutex::new( + LengthenDefaults { mode: ValueMode::Total, delta: 0.0, total: 1.0, percent: 100.0, delta_angle: 0.0, total_angle: 180.0 } +); + enum LenState { - PickEntity, - PickOption { handle: Handle, pick_pt: Vec3 }, + ChooseMode, + Value(ValueMode), + Apply(LenMode), + DynamicPick, + DynamicPoint { handle: Handle, entity: EntityType, pick: DVec3 }, } impl LengthenCommand { pub fn new() -> Self { - Self { - state: LenState::PickEntity, - } + Self { state: LenState::ChooseMode, picked: None, measurement: None, edits: 0 } } } impl CadCommand for LengthenCommand { - fn name(&self) -> &'static str { - "LENGTHEN" - } + fn name(&self) -> &'static str { "LENGTHEN" } fn prompt(&self) -> String { match &self.state { - LenState::PickEntity => t!("LENGTHEN Select object:").into_owned(), - LenState::PickOption { .. } => { - t!("LENGTHEN Enter option [DE / TO / P ]:").into_owned() + LenState::ChooseMode => { + let prompt = t!("LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]:"); + self.measurement.map_or_else(|| prompt.to_string(), |length| format!("Length: {length:.4} {prompt}")) } + LenState::Value(ValueMode::Delta) => format!("LENGTHEN Enter delta length <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().delta), + LenState::Value(ValueMode::Total) => format!("LENGTHEN Enter total length <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().total), + LenState::Value(ValueMode::Percent) => format!("LENGTHEN Enter percentage length <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().percent), + LenState::Value(ValueMode::DeltaAngle) => format!("LENGTHEN Enter delta angle <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().delta_angle), + LenState::Value(ValueMode::TotalAngle) => format!("LENGTHEN Enter total angle <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().total_angle), + LenState::DynamicPoint { .. } => "LENGTHEN Specify new end point:".into(), + LenState::Apply(_) | LenState::DynamicPick | LenState::Value(ValueMode::Dynamic) => t!("LENGTHEN Select an object to change or [Undo]:").into_owned(), } } - fn needs_entity_pick(&self) -> bool { - matches!(self.state, LenState::PickEntity) - } - - fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { let pt = pt.as_vec3(); - if handle.is_null() { - return CmdResult::NeedPoint; + fn options(&self) -> Vec { + use crate::command::CmdOption; + match self.state { + LenState::ChooseMode => vec![CmdOption::new("Delta", "DE"), CmdOption::new("Percent", "P"), CmdOption::new("Total", "TO"), CmdOption::new("Dynamic", "DY")], + LenState::Value(ValueMode::Delta | ValueMode::Total) => vec![CmdOption::new("Angle", "A")], + LenState::Apply(_) | LenState::DynamicPick => vec![CmdOption::new("Undo", "U")], + _ => Vec::new(), } - self.state = LenState::PickOption { - handle, - pick_pt: pt, - }; - CmdResult::NeedPoint } - fn wants_text_input(&self) -> bool { - matches!(self.state, LenState::PickOption { .. }) + fn needs_entity_pick(&self) -> bool { matches!(self.state, LenState::ChooseMode | LenState::Apply(_) | LenState::DynamicPick) } + fn inject_before_entity_pick(&self) -> bool { true } + fn inject_picked_entity(&mut self, entity: EntityType) { self.picked = Some(entity); } + + fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { + if handle.is_null() { return CmdResult::NeedPoint; } + let Some(entity) = self.picked.take() else { return CmdResult::NeedPoint; }; + match &self.state { + LenState::ChooseMode => { + self.measurement = crate::entities::curve::entity_curve(&entity) + .map(|curve| curve.curve.length()).filter(|length| length.is_finite()); + CmdResult::NeedPoint + } + LenState::Apply(mode) => match lengthen_entity_precise(&entity, pt, mode) { + Some(replacement) => CmdResult::ReplaceManyContinue(vec![(handle, vec![replacement])]), + None => CmdResult::NeedPoint, + }, + LenState::DynamicPick if matches!(entity, EntityType::Line(_) | EntityType::Arc(_)) => { + self.state = LenState::DynamicPoint { handle, entity, pick: pt }; + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + } } + fn on_entity_replaced(&mut self, _old: Handle, _new_handles: &[Handle]) { self.edits += 1; } + fn wants_text_input(&self) -> bool { true } + fn dyn_commit_as_text(&self) -> bool { matches!(self.state, LenState::Value(_)) } + fn dyn_auto_sign_angle(&self) -> bool { false } fn dyn_field(&self) -> crate::command::DynField { - if matches!(self.state, LenState::PickOption { .. }) { - crate::command::DynField::Scalar - } else { - crate::command::DynField::Point - } + if matches!(self.state, LenState::Value(ValueMode::DeltaAngle | ValueMode::TotalAngle)) { crate::command::DynField::Angle } + else if matches!(self.state, LenState::Value(_)) { crate::command::DynField::Scalar } + else { crate::command::DynField::Point } } fn on_text_input(&mut self, text: &str) -> Option { - let (handle, pick_pt) = match &self.state { - LenState::PickOption { handle, pick_pt } => (*handle, *pick_pt), - _ => return None, - }; - let pick_pt = pick_pt.as_dvec3(); - - let text = text.trim().to_uppercase(); - if let Some(rest) = text.strip_prefix("DE ").or_else(|| text.strip_prefix("DE")) { - let delta: f64 = rest.trim().replace(',', ".").parse().ok()?; - Some(CmdResult::LengthenEntity { - handle, - pick_pt, - mode: LenMode::Delta(delta), - }) - } else if let Some(rest) = text.strip_prefix("TO ").or_else(|| text.strip_prefix("TO")) { - let total: f64 = rest - .trim() - .replace(',', ".") - .parse() - .ok() - .filter(|&v: &f64| v > 0.0)?; - Some(CmdResult::LengthenEntity { - handle, - pick_pt, - mode: LenMode::Total(total), - }) - } else if let Some(rest) = text.strip_prefix("P ").or_else(|| text.strip_prefix("P")) { - let pct: f64 = rest - .trim() - .replace(',', ".") - .parse() - .ok() - .filter(|&v: &f64| v > 0.0)?; - Some(CmdResult::LengthenEntity { - handle, - pick_pt, - mode: LenMode::Percent(pct), - }) - } else { - // Try plain number as delta - let delta: f64 = text.replace(',', ".").parse().ok()?; - Some(CmdResult::LengthenEntity { - handle, - pick_pt, - mode: LenMode::Delta(delta), - }) + if matches!(self.state, LenState::DynamicPoint { .. }) { return None; } + let upper = text.trim().to_uppercase(); + if upper.is_empty() { return Some(self.on_enter()); } + if matches!(self.state, LenState::Apply(_) | LenState::DynamicPick) { + return Some(if matches!(upper.as_str(), "U" | "UNDO") && self.edits > 0 { + self.edits -= 1; + CmdResult::UndoDocument + } else { CmdResult::NeedPoint }); } + if matches!(self.state, LenState::ChooseMode) { + let mut parts = upper.split_whitespace(); + let mode = match parts.next().unwrap_or("") { + "D" | "DE" | "DELTA" => ValueMode::Delta, + "T" | "TO" | "TOTAL" => ValueMode::Total, + "P" | "PERCENT" => ValueMode::Percent, + "DY" | "DYNAMIC" => ValueMode::Dynamic, + _ => return Some(CmdResult::NeedPoint), + }; + LENGTHEN_DEFAULTS.lock().unwrap().mode = mode; + self.state = if matches!(mode, ValueMode::Dynamic) { LenState::DynamicPick } else { LenState::Value(mode) }; + if let Some(value) = parts.next() { return self.on_text_input(value); } + return Some(CmdResult::NeedPoint); + } + let LenState::Value(mode) = self.state else { return Some(CmdResult::NeedPoint); }; + if matches!(upper.as_str(), "A" | "ANGLE") { + self.state = match mode { + ValueMode::Delta => LenState::Value(ValueMode::DeltaAngle), + ValueMode::Total => LenState::Value(ValueMode::TotalAngle), + _ => return Some(CmdResult::NeedPoint), + }; + return Some(CmdResult::NeedPoint); + } + let Some(value) = upper.replace(',', ".").parse::().ok().filter(|v| v.is_finite()) else { + return Some(CmdResult::NeedPoint); + }; + if !matches!(mode, ValueMode::Delta | ValueMode::DeltaAngle) && value <= 0.0 { return Some(CmdResult::NeedPoint); } + if matches!(mode, ValueMode::TotalAngle) && value >= 360.0 { return Some(CmdResult::NeedPoint); } + { + let mut defaults = LENGTHEN_DEFAULTS.lock().unwrap(); + match mode { ValueMode::Delta => defaults.delta = value, ValueMode::Total => defaults.total = value, ValueMode::Percent => defaults.percent = value, + ValueMode::DeltaAngle => defaults.delta_angle = value, ValueMode::TotalAngle => defaults.total_angle = value, ValueMode::Dynamic => {} } + } + self.state = LenState::Apply(match mode { + ValueMode::Delta => LenMode::Delta(value), + ValueMode::Total => LenMode::Total(value), + ValueMode::Percent => LenMode::Percent(value), + ValueMode::DeltaAngle => LenMode::DeltaAngle(value.to_radians()), + ValueMode::TotalAngle => LenMode::TotalAngle(value.to_radians()), + ValueMode::Dynamic => return Some(CmdResult::NeedPoint), + }); + Some(CmdResult::NeedPoint) } - fn on_point(&mut self, _pt: DVec3) -> CmdResult { + fn on_point(&mut self, pt: DVec3) -> CmdResult { + if let LenState::DynamicPoint { handle, entity, pick } = &self.state { + if let Some(replacement) = lengthen_entity_precise(entity, *pick, &LenMode::Dynamic(pt)) { + let handle = *handle; + self.state = LenState::DynamicPick; + return CmdResult::ReplaceManyContinue(vec![(handle, vec![replacement])]); + } + } CmdResult::NeedPoint } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + let LenState::DynamicPoint { entity, pick, .. } = &self.state else { return None; }; + let replacement = lengthen_entity_precise(entity, *pick, &LenMode::Dynamic(pt))?; + let curve = crate::entities::curve::entity_curve(&replacement)?; + let points = crate::entities::curve::curve_points(&curve).into_iter() + .map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(); + Some(crate::scene::model::wire_model::WireModel::solid( + "lengthen_dynamic_preview".into(), points, + crate::scene::model::wire_model::WireModel::CYAN, false, + )) + } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.state { + LenState::ChooseMode => { + let mode = LENGTHEN_DEFAULTS.lock().unwrap().mode; + self.state = if matches!(mode, ValueMode::Dynamic) { LenState::DynamicPick } else { LenState::Value(mode) }; + CmdResult::NeedPoint + } + LenState::Value(mode) => { + let value = { + let defaults = LENGTHEN_DEFAULTS.lock().unwrap(); + match mode { ValueMode::Delta => defaults.delta, ValueMode::Total => defaults.total, ValueMode::Percent => defaults.percent, + ValueMode::DeltaAngle => defaults.delta_angle, ValueMode::TotalAngle => defaults.total_angle, ValueMode::Dynamic => 0.0 } + }; + self.on_text_input(&value.to_string()).unwrap_or(CmdResult::NeedPoint) + } + _ => CmdResult::Cancel, + } } } - // ── Mode enum (also used in CmdResult) ──────────────────────────────────── #[derive(Clone)] @@ -146,6 +217,9 @@ pub enum LenMode { Delta(f64), Total(f64), Percent(f64), + DeltaAngle(f64), + TotalAngle(f64), + Dynamic(DVec3), } // ── Geometry ─────────────────────────────────────────────────────────────── @@ -153,96 +227,48 @@ pub enum LenMode { /// Apply LENGTHEN to a Line, Arc, Ellipse, or Spline. /// `pick_pt` determines which end to extend/trim (closest end is modified). pub fn lengthen_entity(entity: &EntityType, pick_pt: Vec3, mode: &LenMode) -> Option { + lengthen_entity_precise(entity, pick_pt.as_dvec3(), mode) +} + +fn lengthen_entity_precise(entity: &EntityType, pick: DVec3, mode: &LenMode) -> Option { + use cadkernel::space::lengthen::{LengthChange, lengthen_line, lengthen_arc}; + let change = match mode { + LenMode::Delta(value) => LengthChange::Delta(*value), + LenMode::Total(value) => LengthChange::Total(*value), + LenMode::Percent(value) => LengthChange::Percent(*value), + LenMode::DeltaAngle(value) => LengthChange::DeltaAngle(*value), + LenMode::TotalAngle(value) => LengthChange::TotalAngle(*value), + LenMode::Dynamic(point) => LengthChange::Dynamic(point.to_array()), + }; match entity { - EntityType::Line(l) => lengthen_line(l, pick_pt, mode), - EntityType::Arc(a) => lengthen_arc(a, pick_pt, mode), - EntityType::Ellipse(e) => lengthen_ellipse(e, pick_pt, mode), - EntityType::Spline(s) => lengthen_spline(s, pick_pt, mode), + EntityType::Line(line) => { + let [start, end] = lengthen_line( + [line.start.x, line.start.y, line.start.z], + [line.end.x, line.end.y, line.end.z], pick.to_array(), change)?; + let mut result = line.clone(); + result.common.handle = Handle::NULL; + result.start = Vector3::new(start[0], start[1], start[2]); + result.end = Vector3::new(end[0], end[1], end[2]); + Some(EntityType::Line(result)) + } + EntityType::Arc(arc) => { + let (start, end) = lengthen_arc(&crate::entities::curve::arc_curve(arc), pick.to_array(), change)?; + let mut result = arc.clone(); + result.common.handle = Handle::NULL; + result.start_angle = start; + result.end_angle = end; + Some(EntityType::Arc(result)) + } + EntityType::Ellipse(e) => lengthen_ellipse(e, pick.as_vec3(), mode), + EntityType::Spline(s) => lengthen_spline(s, pick.as_vec3(), mode), EntityType::LwPolyline(p) => { let p = crate::entities::curve::lwpolyline_world_xy(p)?; - lengthen_lwpoly(&p, pick_pt, mode) + lengthen_lwpoly(&p, pick.as_vec3(), mode) } _ => None, } } -fn lengthen_line(line: &LineEnt, pick_pt: Vec3, mode: &LenMode) -> Option { - // Keep the line's own coordinates on the f64 grid; only pick_pt (screen-only, - // nearest-end selection) widens for the distance compare. - let sx = line.start.x; - let sy = line.start.y; - let ex = line.end.x; - let ey = line.end.y; - - let dx = ex - sx; - let dy = ey - sy; - let current_len = (dx * dx + dy * dy).sqrt(); - if current_len < 1e-10 { - return None; - } - - let new_len = apply_mode(current_len, mode)?; - if new_len < 1e-10 { - return None; - } - - let ux = dx / current_len; - let uy = dy / current_len; - - // Which end is closer to pick? - let px = pick_pt.x as f64; - let py = pick_pt.y as f64; - let dist_to_start = (px - sx).hypot(py - sy); - let dist_to_end = (px - ex).hypot(py - ey); - - let mut result = line.clone(); - result.common.handle = Handle::NULL; - - if dist_to_end <= dist_to_start { - // Extend/trim the end - let new_x = sx + ux * new_len; - let new_y = sy + uy * new_len; - result.end = Vector3::new(new_x, new_y, line.end.z); - } else { - // Extend/trim the start (move start backward along dir) - let new_x = ex - ux * new_len; - let new_y = ey - uy * new_len; - result.start = Vector3::new(new_x, new_y, line.start.z); - } - Some(EntityType::Line(result)) -} - -fn lengthen_arc(arc: &ArcEnt, pick_pt: Vec3, mode: &LenMode) -> Option { - // Current arc span - let span = arc_span_rad(arc.start_angle, arc.end_angle); - let current_arc_len = arc.radius * span; - - let new_arc_len = apply_mode(current_arc_len, mode)?; - if new_arc_len < 1e-10 || new_arc_len >= std::f64::consts::TAU * arc.radius - 1.0e-9 { - return None; - } - let new_span = new_arc_len / arc.radius; - - // Which end (start or end angle) is closer to pick? - let curve = crate::entities::curve::arc_curve(arc); - let start_pt = Vec3::from_array(curve.point_at(0.0).map(|value| value as f32)); - let end_pt = Vec3::from_array(curve.point_at(1.0).map(|value| value as f32)); - let dist_start = (pick_pt - start_pt).length(); - let dist_end = (pick_pt - end_pt).length(); - - let mut result = arc.clone(); - result.common.handle = Handle::NULL; - - if dist_end <= dist_start { - // Extend end angle - result.end_angle = arc.start_angle + new_span; - } else { - // Extend start angle (move start backwards) - result.start_angle = arc.end_angle - new_span; - } - Some(EntityType::Arc(result)) -} - fn lengthen_ellipse(ell: &EllipseEnt, pick_pt: Vec3, mode: &LenMode) -> Option { let a = (ell.major_axis.x.powi(2) + ell.major_axis.y.powi(2)).sqrt(); if a < 1e-9 { @@ -318,15 +344,7 @@ fn apply_mode(current: f64, mode: &LenMode) -> Option { LenMode::Delta(d) => Some(current + d), LenMode::Total(t) => Some(*t), LenMode::Percent(p) => Some(current * p / 100.0), - } -} - -fn arc_span_rad(start: f64, end: f64) -> f64 { - let span = (end - start).rem_euclid(TAU); - if span < 1e-6 { - TAU - } else { - span + _ => None, } } @@ -436,3 +454,89 @@ fn lengthen_spline(spl: &SplineEnt, pick_pt: Vec3, mode: &LenMode) -> Option EntityType { + EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(0.0, 0.0, 4.0), + )) + } + + #[test] + fn repeated_delta_edits_offer_one_undo_per_replacement() { + let handle = Handle::new(11); + let mut command = LengthenCommand::new(); + assert!(matches!(command.on_text_input("DE 2"), Some(CmdResult::NeedPoint))); + + for _ in 0..2 { + command.inject_picked_entity(spatial_line()); + let CmdResult::ReplaceManyContinue(replacements) = + command.on_entity_pick(handle, DVec3::new(0.0, 0.0, 4.0)) + else { + panic!("delta edit must replace the picked line"); + }; + let EntityType::Line(line) = &replacements[0].1[0] else { + panic!("replacement must stay a line"); + }; + assert_eq!(line.end, Vector3::new(0.0, 0.0, 6.0)); + command.on_entity_replaced(handle, &[]); + } + + assert!(matches!(command.on_text_input("U"), Some(CmdResult::UndoDocument))); + assert!(matches!(command.on_text_input("U"), Some(CmdResult::UndoDocument))); + assert!(matches!(command.on_text_input("U"), Some(CmdResult::NeedPoint))); + } + + #[test] + fn dynamic_line_endpoint_is_projected_onto_its_spatial_support() { + let handle = Handle::new(12); + let mut command = LengthenCommand::new(); + assert!(matches!(command.on_text_input("DY"), Some(CmdResult::NeedPoint))); + command.inject_picked_entity(spatial_line()); + assert!(matches!( + command.on_entity_pick(handle, DVec3::new(0.0, 0.0, 4.0)), + CmdResult::NeedPoint + )); + + let CmdResult::ReplaceManyContinue(replacements) = + command.on_point(DVec3::new(2.0, -3.0, 7.0)) + else { + panic!("dynamic point must replace the line"); + }; + let EntityType::Line(line) = &replacements[0].1[0] else { + panic!("replacement must stay a line"); + }; + assert_eq!(line.start, Vector3::new(0.0, 0.0, 0.0)); + assert_eq!(line.end, Vector3::new(0.0, 0.0, 7.0)); + assert!(command.needs_entity_pick()); + } + + #[test] + fn circular_arc_accepts_total_angle_in_radians() { + let arc = acadrust::entities::Arc::from_center_radius_angles( + Vector3::ZERO, + 2.0, + 0.0, + std::f64::consts::FRAC_PI_2, + ); + let EntityType::Arc(result) = lengthen_entity_precise( + &EntityType::Arc(arc), + DVec3::new(0.0, 2.0, 0.0), + &LenMode::TotalAngle(std::f64::consts::PI), + ) + .expect("valid angular total") + else { + panic!("replacement must stay an arc"); + }; + assert!((result.end_angle - result.start_angle - std::f64::consts::PI).abs() < 1.0e-12); + } + + #[test] + fn default_total_angle_is_a_half_turn_in_degrees() { + assert_eq!(LENGTHEN_DEFAULTS.lock().unwrap().total_angle, 180.0); + } +} diff --git a/src/modules/draw/modify/mod.rs b/src/modules/draw/modify/mod.rs index a497c4c4e..93c2e6aac 100644 --- a/src/modules/draw/modify/mod.rs +++ b/src/modules/draw/modify/mod.rs @@ -13,13 +13,16 @@ pub mod join; pub mod lengthen; pub mod mirror; pub mod mledit; +pub mod ncopy; pub mod offset; +pub mod overkill; pub mod pedit; pub mod block_edit; pub mod refedit; pub mod reverse; pub mod rotate; pub mod scale; +pub mod setbylayer; pub mod spline_ops; pub mod splinedit; pub mod stretch; diff --git a/src/modules/draw/modify/ncopy.rs b/src/modules/draw/modify/ncopy.rs new file mode 100644 index 000000000..6dcdd08c3 --- /dev/null +++ b/src/modules/draw/modify/ncopy.rs @@ -0,0 +1,226 @@ +use acadrust::{CadDocument, EntityType, Handle}; +use glam::DVec3; +use acadrust::nested_copy::{NestedCopyMode, NestedCopySymbolNames}; +use std::collections::{HashMap, HashSet}; +use crate::command::{CadCommand, CmdOption, CmdResult, EntityTransform}; +use crate::entities::traits::EntityTypeOps; +use crate::scene::convert::acad_to_render::RenderObject; + +enum Step { Select, Settings, Base, Target(DVec3), ArrayCount(DVec3), ArrayTarget(DVec3, usize, bool) } +pub struct NcopyCommand { + nested: HashMap>, + hit_paths: HashMap>>, + selected: Vec<(Handle, usize)>, + step: Step, + multiple: bool, + placements: usize, + mode: NestedCopyMode, + insert_names: NestedCopySymbolNames, + bind_names: NestedCopySymbolNames, +} +impl NcopyCommand { + pub fn new(document: &CadDocument, bind: bool) -> Self { + fn leaves(entity: &EntityType, document: &CadDocument, path: &mut HashSet, output: &mut Vec) { + let EntityType::Insert(insert) = entity else { output.push(entity.clone()); return; }; + let name = insert.block_name.to_ascii_uppercase(); + if !path.insert(name.clone()) { return; } + for child in super::explode::explode_entity(entity, document) { + leaves(&child, document, path, output); + } + path.remove(&name); + } + let nested: HashMap<_, _> = document.entities().filter(|e| matches!(e, EntityType::Insert(_))) + .map(|e| { + let mut entities = Vec::new(); + leaves(e, document, &mut HashSet::new(), &mut entities); + (e.common().handle, entities) + }).collect(); + let hit_paths = nested.iter().map(|(&handle, entities)| { + let paths = entities.iter().map(|entity| { + if crate::entities::curve::entity_curve(entity).is_some() { return Vec::new(); } + match entity.to_render_entity(document).map(|render| render.object) { + Some(RenderObject::Dot(point)) => vec![point], + Some(RenderObject::Lines(points) | RenderObject::SegmentedLines(points) + | RenderObject::TaperedLines(points, _) | RenderObject::BoundaryLines { points, .. }) => points, + _ => Vec::new(), + } + }).collect(); + (handle, paths) + }).collect(); + Self { nested, hit_paths, selected: Vec::new(), step: Step::Select, multiple: false, placements: 0, + mode: if bind { NestedCopyMode::Bind } else { NestedCopyMode::Insert }, + insert_names: document.nested_copy_symbol_names(NestedCopyMode::Insert), + bind_names: document.nested_copy_symbol_names(NestedCopyMode::Bind), + } + } + fn place(&self, delta: DVec3) -> CmdResult { + if !delta.is_finite() { return CmdResult::NeedPoint; } + let copies = self.selected.iter().filter_map(|(h, index)| self.nested.get(h)?.get(*index)).cloned() + .map(|mut entity| { + crate::scene::view::dispatch::apply_transform(&mut entity, &EntityTransform::Translate(delta)); + entity.common_mut().handle = Handle::NULL; + entity + }).collect(); + if self.multiple { CmdResult::CommitEntities(copies) } + else { CmdResult::CommitEntitiesAndExit(copies) } + } +} +impl CadCommand for NcopyCommand { + fn name(&self) -> &'static str { "NCOPY" } + fn prompt(&self) -> String { + match self.step { + Step::Select => format!("Current setting: {}. Select nested objects or [Settings]:", if self.mode == NestedCopyMode::Bind { "Bind" } else { "Insert" }), + Step::Settings => format!("Enter setting [Insert/Bind] <{}>:", if self.mode == NestedCopyMode::Bind { "Bind" } else { "Insert" }), + Step::Base => "Specify base point or [Displacement/Multiple] :".into(), + Step::Target(_) => "Specify second point or [Array] :".into(), + Step::ArrayCount(_) => "Enter number of items to array:".into(), + Step::ArrayTarget(_, _, false) => "Specify second point or [Fit]:".into(), + Step::ArrayTarget(_, _, true) => "Specify last point:".into(), + } + } + fn options(&self) -> Vec { + match self.step { + Step::Select => vec![CmdOption::new("Settings", "S")], + Step::Settings => vec![CmdOption::new("Insert", "I"), CmdOption::new("Bind", "B")], + Step::Base => vec![CmdOption::new("Displacement", "D"), CmdOption::new("Multiple", "M")], + Step::Target(_) if self.multiple && self.placements > 0 => vec![CmdOption::new("Array", "A"), CmdOption::new("Exit", "E"), CmdOption::new("Undo", "U")], + Step::Target(_) => vec![CmdOption::new("Array", "A")], + Step::ArrayTarget(_, _, false) => vec![CmdOption::new("Fit", "F")], + _ => Vec::new(), + } + } + fn nested_copy_bind_setting(&self) -> Option { Some(self.mode == NestedCopyMode::Bind) } + fn nested_copy_symbol_names(&self) -> Option<&NestedCopySymbolNames> { + Some(if self.mode == NestedCopyMode::Bind { &self.bind_names } else { &self.insert_names }) + } + fn preserve_commit_style(&self) -> bool { true } + fn preserve_commit_layer(&self) -> bool { true } + fn needs_entity_pick(&self) -> bool { matches!(self.step, Step::Select) } + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + if let Some(entities) = self.nested.get(&handle) { + let nearest = entities.iter().enumerate().filter_map(|(index, entity)| { + let distance = if let Some(curve) = crate::entities::curve::entity_curve(entity) { + let projected = curve.plane.project([point.x, point.y, point.z])?; + cadkernel::geom2d::closest_point(&curve.curve, projected).distance + .hypot(curve.plane.distance_to(point.to_array())?) + } else { + let path = self.hit_paths.get(&handle)?.get(index)?; + let point = cadkernel::space::Vec3::from(point.to_array()); + if path.len() == 1 { + point.distance(cadkernel::space::Vec3::from(path[0])) + } else { + path.windows(2).filter(|segment| segment.iter().flatten().all(|v| v.is_finite())) + .map(|segment| point.distance_to_segment(segment[0].into(), segment[1].into())) + .min_by(f64::total_cmp)? + } + }; + Some((index, distance)) + }).min_by(|a, b| a.1.total_cmp(&b.1)); + if let Some((index, _)) = nearest { + if !self.selected.contains(&(handle, index)) { self.selected.push((handle, index)); } + } + } + CmdResult::NeedPoint + } + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::Select | Step::Settings => CmdResult::NeedPoint, + Step::Base => { self.step = Step::Target(point); CmdResult::NeedPoint } + Step::Target(base) => { + if !point.is_finite() { return CmdResult::NeedPoint; } + self.placements += 1; + self.place(point - base) + } + Step::ArrayCount(_) => CmdResult::NeedPoint, + Step::ArrayTarget(base, count, fit) => { + let delta = (point - base) / if fit { (count - 1) as f64 } else { 1.0 }; + if !delta.is_finite() { return CmdResult::NeedPoint; } + let mut copies = Vec::new(); + for index in 0..count { + if let CmdResult::CommitEntities(mut entities) | CmdResult::CommitEntitiesAndExit(mut entities) = self.place(delta * index as f64) { + copies.append(&mut entities); + } + } + CmdResult::CommitEntitiesAndExit(copies) + } + } + } + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::Settings => { self.step = Step::Select; CmdResult::NeedPoint } + Step::Select if !self.selected.is_empty() => { self.step = Step::Base; CmdResult::NeedPoint } + Step::Base => { self.step = Step::Target(DVec3::ZERO); CmdResult::NeedPoint } + Step::Target(base) if !self.multiple => self.place(base), + _ => CmdResult::Cancel, + } + } + fn point_step_accepts_keywords(&self) -> bool { matches!(self.step, Step::Select | Step::Settings | Step::Base | Step::Target(_) | Step::ArrayTarget(..)) } + fn wants_text_input(&self) -> bool { true } + fn on_text_input(&mut self, text: &str) -> Option { + if matches!(self.step, Step::Settings) { + match text.trim().to_uppercase().as_str() { + "I" | "INSERT" => self.mode = NestedCopyMode::Insert, + "B" | "BIND" => self.mode = NestedCopyMode::Bind, + _ => return Some(CmdResult::NeedPoint), + } + self.step = Step::Select; + return Some(CmdResult::NeedPoint); + } + if let Step::ArrayCount(base) = self.step { + if let Ok(count) = text.trim().parse::() { + if (2..=32767).contains(&count) { self.step = Step::ArrayTarget(base, count, false); } + } + return Some(CmdResult::NeedPoint); + } + match text.trim().to_uppercase().as_str() { + "S" | "SETTINGS" if matches!(self.step, Step::Select) => { self.step = Step::Settings; Some(CmdResult::NeedPoint) } + "A" | "ARRAY" => { + if let Step::Target(base) = self.step { self.step = Step::ArrayCount(base); } + Some(CmdResult::NeedPoint) + } + "F" | "FIT" => { + if let Step::ArrayTarget(base, count, _) = self.step { self.step = Step::ArrayTarget(base, count, true); } + Some(CmdResult::NeedPoint) + } + "U" | "UNDO" if self.multiple && self.placements > 0 => { + self.placements -= 1; + Some(CmdResult::UndoDocument) + } + "E" | "EXIT" if self.multiple => Some(CmdResult::Cancel), + "M" | "MULTIPLE" if matches!(self.step, Step::Base) => { self.multiple = true; Some(CmdResult::NeedPoint) } + "D" | "DISPLACEMENT" if matches!(self.step, Step::Base) => { self.step = Step::Target(DVec3::ZERO); Some(CmdResult::NeedPoint) } + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::Line; + use acadrust::types::Vector3; + + #[test] + fn placement_moves_only_geometry_and_keeps_source_style() { + let handle = Handle::new(7); + let mut line = Line::from_points(Vector3::ZERO, Vector3::new(2.0, 0.0, 0.0)); + line.common.handle = handle; + line.common.layer = "DETAIL".into(); + line.common.linetype = "DASHED".into(); + let mut nested = HashMap::new(); + nested.insert(handle, vec![EntityType::Line(line)]); + let command = NcopyCommand { nested, hit_paths: HashMap::new(), selected: vec![(handle, 0)], + step: Step::Base, multiple: false, placements: 0, + mode: NestedCopyMode::Insert, + insert_names: NestedCopySymbolNames::default(), + bind_names: NestedCopySymbolNames::default() }; + + let CmdResult::CommitEntitiesAndExit(entities) = command.place(DVec3::new(3.0, 4.0, 5.0)) + else { panic!("expected copied entity") }; + let EntityType::Line(line) = &entities[0] else { panic!("expected line") }; + assert!(line.common.handle.is_null()); + assert_eq!((line.common.layer.as_str(), line.common.linetype.as_str()), ("DETAIL", "DASHED")); + assert_eq!(line.start, Vector3::new(3.0, 4.0, 5.0)); + assert_eq!(line.end, Vector3::new(5.0, 4.0, 5.0)); + } +} diff --git a/src/modules/draw/modify/overkill.rs b/src/modules/draw/modify/overkill.rs new file mode 100644 index 000000000..2c7df109c --- /dev/null +++ b/src/modules/draw/modify/overkill.rs @@ -0,0 +1,178 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; +use crate::command::{CadCommand, CmdOption, CmdResult}; + +#[derive(Clone, Copy)] +pub struct Settings { + pub tolerance: f64, + pub ignore: u16, + pub optimize: bool, + pub overlap: bool, + pub end_to_end: bool, + pub associativity: bool, +} +impl Default for Settings { + fn default() -> Self { Self { tolerance: 1e-6, ignore: 0, optimize: true, + overlap: true, end_to_end: true, associativity: true } } +} +fn remembered() -> &'static std::sync::Mutex { + static SETTINGS: std::sync::OnceLock> = std::sync::OnceLock::new(); + SETTINGS.get_or_init(|| std::sync::Mutex::new(Settings::default())) +} +enum Step { Selection, Options, Ignore, Tolerance, Optimize, Overlap, EndToEnd, Associativity } +pub struct OverkillCommand { selected: Vec, step: Step, settings: Settings } +impl OverkillCommand { + pub fn new(selected: Vec) -> Self { + let step = if selected.is_empty() { Step::Selection } else { Step::Options }; + Self { selected, step, settings: *remembered().lock().unwrap_or_else(|e|e.into_inner()) } + } + fn finish(&self) -> CmdResult { + let s = self.settings; + *remembered().lock().unwrap_or_else(|e|e.into_inner())=s; + CmdResult::Relaunch(format!("OVERKILL_APPLY {} {} {} {} {} {}", s.tolerance, + s.ignore, u8::from(s.optimize), u8::from(s.overlap), u8::from(s.end_to_end), + u8::from(s.associativity)), self.selected.clone()) + } +} +impl CadCommand for OverkillCommand { + fn name(&self) -> &'static str { "OVERKILL" } + fn prompt(&self) -> String { + match self.step { + Step::Selection => "Select objects:".into(), + Step::Options => format!("Tolerance={} Ignore={} Optimize={} Overlap={} End-to-end={}\nEnter an option [Done/Ignore/tOlerance/optimize Plines/combine parTial overlap/combine Endtoend/Associativity] :", self.settings.tolerance, self.settings.ignore, self.settings.optimize, self.settings.overlap, self.settings.end_to_end), + Step::Ignore => "Properties to ignore [None/All/Color/LAyer/Ltype/ltScale/LWeight/Thickness/TRansparency/plotSTyle/Material]:".into(), + Step::Tolerance => format!("Specify tolerance <{}>:", self.settings.tolerance), + Step::Optimize => format!("Optimize segments within polylines [Yes/No] <{}>:",if self.settings.optimize{"Yes"}else{"No"}), + Step::Overlap => format!("Combine partially overlapping objects [Yes/No] <{}>:",if self.settings.overlap{"Yes"}else{"No"}), + Step::EndToEnd => format!("Combine end-to-end objects [Yes/No] <{}>:",if self.settings.end_to_end{"Yes"}else{"No"}), + Step::Associativity => format!("Maintain associative objects [Yes/No] <{}>:",if self.settings.associativity{"Yes"}else{"No"}), + } + } + fn options(&self) -> Vec { + match self.step { + Step::Options => [("Done","D"),("Ignore","I"),("Tolerance","O"),("Optimize polylines","P"),("Combine overlap","T"),("Combine end-to-end","E"),("Associativity","A")].into_iter().map(|(l,k)|CmdOption::new(l,k)).collect(), + Step::Optimize|Step::Overlap|Step::EndToEnd|Step::Associativity => vec![CmdOption::new("Yes","Y"),CmdOption::new("No","N")], + _ => Vec::new(), + } + } + fn is_selection_gathering(&self) -> bool { matches!(self.step,Step::Selection) } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { self.selected=handles; CmdResult::NeedPoint } + fn wants_text_input(&self) -> bool { !self.is_selection_gathering() } + fn on_point(&mut self, _:DVec3) -> CmdResult { CmdResult::NeedPoint } + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::Selection if self.selected.is_empty() => CmdResult::Cancel, + Step::Options => self.finish(), + _ => { self.step=Step::Options; CmdResult::NeedPoint } + } + } + fn on_text_input(&mut self, text:&str) -> Option { + let k=text.trim().to_ascii_uppercase(); + match self.step { + Step::Options => match k.as_str() { + "D"|"DONE" => return Some(self.finish()), + "I"|"IGNORE" => self.step=Step::Ignore, + "O"|"TOLERANCE" => self.step=Step::Tolerance, + "P"|"PLINES" => self.step=Step::Optimize, + "T"|"PARTIAL" => self.step=Step::Overlap, + "E"|"ENDTOEND" => self.step=Step::EndToEnd, + "A"|"ASSOCIATIVITY" => self.step=Step::Associativity, + _=>{}, + }, + Step::Ignore => { + let flag=match k.as_str() { "N"|"NONE"=>{self.settings.ignore=0;0}, "A"|"ALL"=>511, + "C"|"COLOR"=>1,"LA"|"LAYER"=>2,"L"|"LTYPE"=>4,"S"|"LTSCALE"=>8, + "LW"|"LWEIGHT"=>16,"T"|"THICKNESS"=>32,"TR"|"TRANSPARENCY"=>64, + "ST"|"PLOTSTYLE"=>128,"M"|"MATERIAL"=>256,_=>return Some(CmdResult::NeedPoint)}; + self.settings.ignore|=flag; self.step=Step::Options; + } + Step::Tolerance => if let Ok(v)=k.parse::() { if v.is_finite()&&v>=0.0 { + self.settings.tolerance=v; self.step=Step::Options; + } }, + Step::Optimize|Step::Overlap|Step::EndToEnd|Step::Associativity => { + let yes=match k.as_str(){"Y"|"YES"=>true,"N"|"NO"=>false,_=>return Some(CmdResult::NeedPoint)}; + match self.step {Step::Optimize=>self.settings.optimize=yes,Step::Overlap=>self.settings.overlap=yes, + Step::EndToEnd=>self.settings.end_to_end=yes,Step::Associativity=>self.settings.associativity=yes,_=>{}} + self.step=Step::Options; + } + _=>{}, + } + Some(CmdResult::NeedPoint) + } +} + +pub fn normalized(entity:&EntityType, ignore:u16) -> EntityType { + let mut e=entity.clone(); + let common=e.common_mut(); + let default=acadrust::entities::EntityCommon::new(); + common.handle=Handle::NULL; + if ignore&1!=0 {common.color=default.color;common.color_name=None;common.color_book_handle=None;} + if ignore&2!=0 {common.layer.clear();} + if ignore&4!=0 {common.linetype.clear();common.linetype_handle=None;} + if ignore&8!=0 {common.linetype_scale=1.0;} + if ignore&16!=0 {common.line_weight=default.line_weight;} + if ignore&64!=0 {common.transparency=default.transparency;} + if ignore&128!=0 {common.plotstyle_flags=0;common.plotstyle_handle=None;} + if ignore&256!=0 {common.material_flags=0;common.material_handle=None;} + if ignore&32!=0 {match &mut e {EntityType::Line(v)=>v.thickness=0.0, + EntityType::Circle(v)=>v.thickness=0.0,EntityType::Arc(v)=>v.thickness=0.0, + EntityType::LwPolyline(v)=>v.thickness=0.0,_=>{}}} + e +} + +pub fn optimize(entity:&mut EntityType, tolerance:f64) { + if let EntityType::LwPolyline(p)=entity { + // Width changes and bulged spans carry geometry that must be retained. + if p.vertices.iter().any(|v|v.bulge!=0.0||v.start_width!=0.0||v.end_width!=0.0) {return;} + let points:Vec<_>=p.vertices.iter().map(|v|[v.location.x,v.location.y,p.elevation]).collect(); + let indices=cadkernel::space::simplify_linear_chain(&points,tolerance); + if indices.len()>=if p.is_closed{3}else{2} { + p.vertices=indices.into_iter().map(|i|p.vertices[i].clone()).collect(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::{Line, LwPolyline}; + use acadrust::types::{Color, Vector2}; + + #[test] + fn normalization_ignores_only_the_requested_properties() { + let mut first = Line::from_coords(0.0, 0.0, 0.0, 2.0, 0.0, 0.0); + first.common.handle = Handle::new(1); + first.common.layer = "FIRST".into(); + first.common.color = Color::from_index(1); + let mut second = first.clone(); + second.common.handle = Handle::new(2); + second.common.layer = "SECOND".into(); + second.common.color = Color::from_index(2); + + assert_ne!(normalized(&EntityType::Line(first.clone()), 0), normalized(&EntityType::Line(second.clone()), 0)); + assert_eq!(normalized(&EntityType::Line(first), 1 | 2), normalized(&EntityType::Line(second), 1 | 2)); + } + + #[test] + fn polyline_optimization_removes_only_plain_collinear_vertices() { + let mut plain = EntityType::LwPolyline(LwPolyline::from_points(vec![ + Vector2::new(0.0, 0.0), + Vector2::new(1.0, 0.0), + Vector2::new(2.0, 0.0), + ])); + optimize(&mut plain, 1e-9); + let EntityType::LwPolyline(plain) = plain else { unreachable!() }; + assert_eq!(plain.vertices.len(), 2); + + let mut styled = LwPolyline::from_points(vec![ + Vector2::new(0.0, 0.0), + Vector2::new(1.0, 0.0), + Vector2::new(2.0, 0.0), + ]); + styled.vertices[1].start_width = 1.0; + let mut styled = EntityType::LwPolyline(styled); + optimize(&mut styled, 1e-9); + let EntityType::LwPolyline(styled) = styled else { unreachable!() }; + assert_eq!(styled.vertices.len(), 3); + } +} diff --git a/src/modules/draw/modify/pedit.rs b/src/modules/draw/modify/pedit.rs index 54e53e178..671504347 100644 --- a/src/modules/draw/modify/pedit.rs +++ b/src/modules/draw/modify/pedit.rs @@ -5,13 +5,14 @@ use acadrust::types::{Vector2, Vector3}; use acadrust::{EntityType, Handle}; use cadkernel::geom2d::nurbs::clamped_uniform_knots; use cadkernel::geom2d::NurbsCurve; -use glam::{DVec2, DVec3}; +use glam::DVec3; use rustc_hash::FxHashMap as HashMap; use crate::command::{CadCommand, CmdResult}; use crate::t; const TAU: f64 = std::f64::consts::TAU; +static JOIN_FUZZ: std::sync::Mutex = std::sync::Mutex::new(0.0); /// What PEDIT knows about a pickable entity, captured at dispatch. #[derive(Clone, Copy)] @@ -29,10 +30,21 @@ pub struct PeditTarget { enum Mode { PickTarget, + MultipleGather, + MultipleConvert, + MultipleJoin, Options, /// Picked a Line/Arc: asking "Turn it into one? [Yes/No]". ConvertPrompt(Handle), AwaitWidth, + AwaitLinetype, + PolyVertex(usize), + PolyTangent(usize), + PolyRange { start: usize, end: usize, split: bool }, + PolyMove(usize), + PolyInsert(usize), + PolyWidthStart(usize, f64), + PolyWidthEnd(usize, f64), /// Join: gathering additional segments; Enter merges. JoinGather(Vec), /// Polygon-mesh vertex navigation uses the mesh's row-major control net. @@ -43,9 +55,17 @@ enum Mode { pub struct PeditCommand { target: Option, + entities: HashMap, + multiple: Vec, + multiple_history: Vec>, + pending_multiple: Option>, + join_fuzz: f64, info: HashMap, mode: Mode, undo_count: usize, + vertex_tangents: HashMap, + pending_tangents: Option>, + tangent_history: Vec>, mesh_smooth_type: acadrust::entities::polygon_mesh::SurfaceSmoothType, mesh_smooth_density: (i16, i16), mesh_vertex_default: isize, @@ -69,9 +89,17 @@ impl PeditCommand { }; Self { target: None, + entities: HashMap::default(), + multiple: Vec::new(), + multiple_history: Vec::new(), + pending_multiple: None, + join_fuzz: *JOIN_FUZZ.lock().unwrap_or_else(|error| error.into_inner()), info, mode: Mode::PickTarget, undo_count: 0, + vertex_tangents: HashMap::default(), + pending_tangents: None, + tangent_history: Vec::new(), mesh_smooth_type, mesh_smooth_density: ( surface_u_density.clamp(2, 200), @@ -86,6 +114,17 @@ impl PeditCommand { /// Adopt a pre-selected entity (pickfirst): a selected polyline skips the /// pick step, a selected line/arc goes straight to the convert prompt. pub fn with_preselection(mut self, handles: &[Handle]) -> Self { + let multiple: Vec<_> = handles.iter().copied().filter(|handle| { + self.info.get(&handle.value()).is_some_and(|info| info.mesh_size.is_none()) + }).collect(); + if multiple.len() > 1 { + self.target = multiple.first().copied(); + self.mode = if multiple.iter().any(|handle| self.info.get(&handle.value()).is_some_and(|info| info.convertible)) { + Mode::MultipleConvert + } else { Mode::Options }; + self.multiple = multiple; + return self; + } for &h in handles { let Some(info) = self.info.get(&h.value()).copied() else { continue; @@ -103,6 +142,54 @@ impl PeditCommand { self } + pub fn with_entities(mut self, entities: impl IntoIterator) -> Self { + self.entities = entities.into_iter().map(|entity| (entity.common().handle.value(), entity)).collect(); + self + } + + fn multiple_result(&self, result: Option) -> Option { + match result { + Some(CmdResult::PeditOp { handle, op }) if !self.multiple.is_empty() => + Some(CmdResult::PeditOp { handle, op: PeditOp::Multiple(self.multiple.clone(), Box::new(op)) }), + other => other, + } + } + + fn mapped_tangents(&self, op: &PeditOp) -> Option> { + let count = self.vertex_count(); + match op { + PeditOp::Reverse => Some(self.vertex_tangents.iter().filter_map(|(&index, &angle)| + (index < count).then_some((count.saturating_sub(index + 1), (angle + std::f64::consts::PI).rem_euclid(TAU)))).collect()), + PeditOp::EditVertex { index, insert: true, .. } => Some(self.vertex_tangents.iter().map(|(&old, &angle)| + (if old > *index { old + 1 } else { old }, angle)).collect()), + PeditOp::VertexRange { first, last, split } => Some(self.vertex_tangents.iter().filter_map(|(&index, &angle)| { + if *split { + if *first > 0 { (index <= *first).then_some((index, angle)) } + else { (index >= *last).then_some((index.saturating_sub(*last), angle)) } + } else if index > *first && index < *last { None } + else { Some((if index >= *last { index.saturating_sub(last.saturating_sub(*first + 1)) } else { index }, angle)) } + }).collect()), + PeditOp::Fit | PeditOp::FitWithTangents(_) | PeditOp::Decurve | PeditOp::Spline => Some(HashMap::default()), + _ => None, + } + } + + fn vertex_width(&self, index: usize) -> f64 { + let entity = self.target.and_then(|handle| self.entities.get(&handle.value())); + match entity { + Some(EntityType::LwPolyline(p)) => if p.constant_width != 0.0 { p.constant_width } else { p.vertices.get(index).map_or(0.0, |v| v.start_width) }, + Some(EntityType::Polyline2D(p)) => p.vertices.get(index).map_or(p.start_width, |v| if v.start_width == 0.0 { p.start_width } else { v.start_width }), + _ => 0.0, + } + } + + fn vertex_count(&self) -> usize { + self.target.and_then(|handle| self.entities.get(&handle.value())).map_or(0, |entity| match entity { + EntityType::LwPolyline(polyline) => polyline.vertices.len(), + EntityType::Polyline2D(polyline) => polyline.vertices.len(), + _ => 0, + }) + } fn mesh_size(&self) -> Option<(usize, usize)> { let handle = self.target?; self.info.get(&handle.value())?.mesh_size @@ -148,13 +235,17 @@ impl CadCommand for PeditCommand { fn prompt(&self) -> String { match &self.mode { Mode::PickTarget => { - t!("PEDIT Select polyline (or a line/arc to convert):").into_owned() + t!("PEDIT Select polyline (or a line/arc to convert) or [Multiple]:").into_owned() } + Mode::MultipleGather => format!("PEDIT Select objects ({} selected, Enter when done):", self.multiple.len()), + Mode::MultipleJoin => format!("PEDIT Join type: Extend. Enter fuzz distance <{}>:", self.join_fuzz), + Mode::MultipleConvert => t!("PEDIT Convert lines and arcs to polylines [Yes/No] :").into_owned(), Mode::ConvertPrompt(_) => t!( "PEDIT Object is not a polyline. Turn it into one? [Yes/No] :" ) .into_owned(), Mode::AwaitWidth => t!("PEDIT Specify new width:").into_owned(), + Mode::AwaitLinetype => t!("PEDIT Enter polyline linetype generation option [ON/OFF]:").into_owned(), Mode::JoinGather(list) => t!( "PEDIT Join Select objects to join (%{count} picked), Enter to merge:", count = list.len().saturating_sub(1) @@ -170,6 +261,13 @@ impl CadCommand for PeditCommand { vertex = index + 1 ) .into_owned(), + Mode::PolyVertex(index) => format!("PEDIT Vertex {} [Next/Previous/Break/Insert/Move/Straighten/Tangent/Width/eXit] :", index + 1), + Mode::PolyRange { end, .. } => format!("PEDIT Vertex {} [Next/Previous/Go/eXit] :", end + 1), + Mode::PolyTangent(_) => "PEDIT Specify direction of vertex tangent:".to_string(), + Mode::PolyMove(index) => format!("PEDIT Specify new location for vertex {}:", index + 1), + Mode::PolyWidthStart(_, width) => format!("PEDIT Specify starting width for next segment <{width}>:"), + Mode::PolyWidthEnd(_, width) => format!("PEDIT Specify ending width for next segment <{width}>:"), + Mode::PolyInsert(index) => format!("PEDIT Specify location after vertex {}:", index + 1), Mode::Options => t!("PEDIT Enter option:").into_owned(), } } @@ -177,6 +275,8 @@ impl CadCommand for PeditCommand { fn options(&self) -> Vec { use crate::command::CmdOption; match &self.mode { + Mode::PickTarget => vec![CmdOption::new("Multiple", "M")], + Mode::MultipleConvert => vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")], Mode::Options if self.mesh_size().is_some() => { let (closed_m, closed_n) = self.mesh_closed().unwrap_or((false, false)); vec![ @@ -205,8 +305,12 @@ impl CadCommand for PeditCommand { CmdOption::new(t!("Fit").as_ref(), "F"), CmdOption::new(t!("Spline").as_ref(), "S"), CmdOption::new(t!("Decurve").as_ref(), "D"), + CmdOption::new(t!("Edit vertex").as_ref(), "E"), + CmdOption::new(t!("Ltype gen").as_ref(), "L"), + CmdOption::new(t!("Reverse").as_ref(), "R"), + CmdOption::new(t!("Undo").as_ref(), "U"), CmdOption::new(t!("eXit").as_ref(), "X"), - ], + ].into_iter().filter(|option| self.multiple.is_empty() || option.keyword != "E").collect(), Mode::MeshVertex(_) => vec![ CmdOption::new(t!("Next").as_ref(), "N"), CmdOption::new(t!("Previous").as_ref(), "P"), @@ -222,6 +326,9 @@ impl CadCommand for PeditCommand { vec![CmdOption::new(t!("Yes").as_ref(), "Y"), CmdOption::new(t!("No").as_ref(), "N")] } Mode::JoinGather(_) => vec![CmdOption::enter(t!("Join").as_ref())], + Mode::PolyVertex(_) => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Break", "B"), CmdOption::new("Straighten", "S"), CmdOption::new("Tangent", "T"), CmdOption::new("Insert", "I"), CmdOption::new("Move", "M"), CmdOption::new("Width", "W"), CmdOption::new("Exit", "X")], + Mode::PolyRange { .. } => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Go", "G"), CmdOption::new("Exit", "X")], + Mode::AwaitLinetype => vec![CmdOption::new("On", "ON"), CmdOption::new("Off", "OFF")], Mode::MeshVertexMove(_) => vec![], _ => vec![], } @@ -234,10 +341,14 @@ impl CadCommand for PeditCommand { fn is_selection_gathering(&self) -> bool { // Join uses the normal selection system, so single picks AND // window/crossing boxes both gather objects. - matches!(self.mode, Mode::JoinGather(_)) + matches!(self.mode, Mode::JoinGather(_) | Mode::MultipleGather) } fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + if matches!(self.mode, Mode::MultipleGather) { + self.multiple = handles.into_iter().filter(|handle| self.info.get(&handle.value()).is_some_and(|info| info.mesh_size.is_none())).collect(); + return CmdResult::NeedPoint; + } if let (Some(target), Mode::JoinGather(list)) = (self.target, &mut self.mode) { list.clear(); list.push(target); @@ -275,7 +386,8 @@ impl CadCommand for PeditCommand { // A Yes-conversion (or Break) replaced the entity — adopt the first // piece as the live target and carry its bookkeeping over. if let Some(&nh) = new_handles.first() { - self.info.remove(&old.value()); + if self.multiple.is_empty() { self.info.remove(&old.value()); } + for handle in &mut self.multiple { if *handle == old { *handle = nh; } } self.info.insert( nh.value(), PeditTarget { @@ -285,13 +397,24 @@ impl CadCommand for PeditCommand { mesh_closed: None, }, ); + let mut seen = std::collections::HashSet::new(); + self.multiple.retain(|handle| seen.insert(*handle)); self.target = Some(nh); self.mode = Mode::Options; } } + fn inject_picked_entity(&mut self, entity: EntityType) { + self.entities.insert(entity.common().handle.value(), entity); + let count = self.vertex_count(); + if let Mode::PolyVertex(index) = &mut self.mode { *index = (*index).min(count.saturating_sub(1)); } + } + fn on_pedit_applied(&mut self) { + self.tangent_history.push(self.vertex_tangents.clone()); + if let Some(mapped) = self.pending_tangents.take() { self.vertex_tangents = mapped; } self.undo_count = self.undo_count.saturating_add(1); + self.multiple_history.push(self.pending_multiple.take().unwrap_or_else(|| self.multiple.clone())); if let Some((m_direction, closed)) = self.pending_mesh_closed.take() { let previous = self.mesh_closed(); self.mesh_closed_history.push(previous); @@ -302,15 +425,46 @@ impl CadCommand for PeditCommand { } fn wants_text_input(&self) -> bool { - !matches!(self.mode, Mode::PickTarget | Mode::MeshVertexMove(_)) + !matches!(self.mode, Mode::PickTarget | Mode::MeshVertexMove(_) | Mode::PolyMove(_) | Mode::PolyInsert(_)) } fn on_text_input(&mut self, text: &str) -> Option { self.pending_mesh_closed = None; + self.pending_tangents = None; let up = text.trim().to_uppercase(); let mesh_size = self.mesh_size(); - match &mut self.mode { - Mode::PickTarget => None, + let vertex_count = self.vertex_count(); + let current_width = if let Mode::PolyVertex(index) = self.mode { self.vertex_width(index) } else { 0.0 }; + let result = match &mut self.mode { + Mode::PickTarget => { + if matches!(up.as_str(), "M" | "MULTIPLE") { self.mode = Mode::MultipleGather; Some(CmdResult::NeedPoint) } else { None } + } + Mode::MultipleGather => None, + Mode::MultipleJoin => { + let fuzz = text.trim().parse::().ok()?; + if !fuzz.is_finite() || fuzz < 0.0 { return Some(CmdResult::ReportError("PEDIT: fuzz distance must be nonnegative and finite.".to_string())); } + self.join_fuzz = fuzz; + *JOIN_FUZZ.lock().unwrap_or_else(|error| error.into_inner()) = fuzz; + self.pending_multiple = Some(self.multiple.clone()); + self.mode = Mode::Options; + return Some(CmdResult::PeditOp { handle: self.target?, op: PeditOp::JoinSelection(self.multiple.clone(), fuzz) }); + }, + Mode::MultipleConvert => { + match up.as_str() { + "Y" | "YES" | "" => { + self.pending_multiple = Some(self.multiple.clone()); + self.mode = Mode::Options; + Some(CmdResult::PeditOp { handle: *self.multiple.first()?, op: PeditOp::ConvertToPolyline }) + } + "N" | "NO" => { + self.multiple.retain(|handle| self.info.get(&handle.value()).is_some_and(|info| info.is_poly)); + self.target = self.multiple.first().copied(); + self.mode = if self.target.is_some() { Mode::Options } else { Mode::PickTarget }; + Some(CmdResult::NeedPoint) + } + _ => Some(CmdResult::NeedPoint), + } + } Mode::ConvertPrompt(handle) => { let handle = *handle; match up.as_str() { @@ -331,14 +485,71 @@ impl CadCommand for PeditCommand { .replace(',', ".") .parse() .ok() - .filter(|&v: &f64| v >= 0.0)?; + .filter(|&v: &f64| v.is_finite() && v >= 0.0)?; self.mode = Mode::Options; Some(CmdResult::PeditOp { handle, op: PeditOp::SetWidth(w), }) } + Mode::AwaitLinetype => { + let enabled = match up.as_str() { "ON" => true, "OFF" => false, _ => return Some(CmdResult::NeedPoint) }; + self.mode = Mode::Options; + Some(CmdResult::PeditOp { handle: self.target?, op: PeditOp::SetLinetypeGeneration(enabled) }) + } + Mode::PolyWidthStart(index, _) => { + let width = text.trim().parse::().ok()?; + if !width.is_finite() || width < 0.0 { return Some(CmdResult::NeedPoint); } + self.mode = Mode::PolyWidthEnd(*index, width); + Some(CmdResult::NeedPoint) + } + Mode::PolyWidthEnd(index, start) => { + let end = text.trim().parse::().ok()?; + if !end.is_finite() || end < 0.0 { return Some(CmdResult::NeedPoint); } + let (index, start) = (*index, *start); + self.mode = Mode::PolyVertex(index); + Some(CmdResult::PeditOp { handle: self.target?, op: PeditOp::SetVertexWidth { index, start, end } }) + } Mode::JoinGather(_) => None, + Mode::PolyMove(_) | Mode::PolyInsert(_) => None, + Mode::PolyTangent(index) => { + let angle = text.trim().parse::().ok()?; + if !angle.is_finite() { return Some(CmdResult::NeedPoint); } + self.vertex_tangents.insert(*index, angle.to_radians().rem_euclid(TAU)); + self.mode = Mode::PolyVertex(*index); + Some(CmdResult::NeedPoint) + } + Mode::PolyRange { start, end, split } => { + match up.as_str() { + "N" | "NEXT" => { if *end + 1 < vertex_count { *end += 1; } } + "P" | "PREVIOUS" => { *end = end.saturating_sub(1); } + "X" | "EXIT" => self.mode = Mode::PolyVertex(*start), + "G" | "GO" => { + let (first, last, split) = ((*start).min(*end), (*start).max(*end), *split); + self.mode = Mode::PolyVertex(first); + let op = PeditOp::VertexRange { first, last, split }; + self.pending_tangents = self.mapped_tangents(&op); + return Some(CmdResult::PeditOp { handle: self.target?, op }); + } + _ => {} + } + Some(CmdResult::NeedPoint) + } + Mode::PolyVertex(index) => { + match up.as_str() { + "N" | "NEXT" => { if *index + 1 < vertex_count { *index += 1; } } + "P" | "PREVIOUS" => { *index = index.saturating_sub(1); } + "T" | "TANGENT" => self.mode = Mode::PolyTangent(*index), + "B" | "BREAK" => self.mode = Mode::PolyRange { start: *index, end: *index, split: true }, + "S" | "STRAIGHTEN" => self.mode = Mode::PolyRange { start: *index, end: *index, split: false }, + "I" | "INSERT" => self.mode = Mode::PolyInsert(*index), + "M" | "MOVE" => self.mode = Mode::PolyMove(*index), + "W" | "WIDTH" => self.mode = Mode::PolyWidthStart(*index, current_width), + "X" | "EXIT" => self.mode = Mode::Options, + _ => {} + } + Some(CmdResult::NeedPoint) + } Mode::MeshVertexMove(_) => None, Mode::MeshVertex(index) => { let (m, n) = mesh_size?; @@ -442,6 +653,20 @@ impl CadCommand for PeditCommand { }; } match up.as_str() { + "E" | "EDIT" if self.multiple.is_empty() => { self.mode = Mode::PolyVertex(0); Some(CmdResult::NeedPoint) } + "R" | "REVERSE" => Some(CmdResult::PeditOp { handle, op: PeditOp::Reverse }), + "L" | "LTYPE" | "LTYPEGEN" => { self.mode = Mode::AwaitLinetype; Some(CmdResult::NeedPoint) } + "U" | "UNDO" if self.undo_count > 0 => { + self.undo_count -= 1; + self.mesh_closed_history.pop(); + if let Some(tangents) = self.tangent_history.pop() { self.vertex_tangents = tangents; } + if let Some(previous) = self.multiple_history.pop() { + self.multiple = previous; + if !self.multiple.is_empty() { self.target = self.multiple.first().copied(); } + } + Some(CmdResult::UndoDocument) + } + "U" | "UNDO" => Some(CmdResult::NeedPoint), "X" | "EXIT" => Some(CmdResult::Cancel), "C" | "CLOSE" => Some(CmdResult::PeditOp { handle, @@ -456,12 +681,12 @@ impl CadCommand for PeditCommand { Some(CmdResult::NeedPoint) } "J" | "JOIN" => { - self.mode = Mode::JoinGather(vec![handle]); + self.mode = if self.multiple.is_empty() { Mode::JoinGather(vec![handle]) } else { Mode::MultipleJoin }; Some(CmdResult::NeedPoint) } "F" | "FIT" => Some(CmdResult::PeditOp { handle, - op: PeditOp::Fit, + op: PeditOp::FitWithTangents(self.vertex_tangents.iter().map(|(index, angle)| (*index, *angle)).collect()), }), "S" | "SPLINE" => Some(CmdResult::PeditOp { handle, @@ -475,21 +700,47 @@ impl CadCommand for PeditCommand { // Inline shorthand `W `. if let Some(rest) = up.strip_prefix("W ") { let w: f64 = rest.trim().replace(',', ".").parse().ok()?; - if w >= 0.0 { - return Some(CmdResult::PeditOp { + if w.is_finite() && w >= 0.0 { + return self.multiple_result(Some(CmdResult::PeditOp { handle, op: PeditOp::SetWidth(w), - }); + })); } } None } } } - } + }; + if let Some(CmdResult::PeditOp { op, .. }) = &result { self.pending_tangents = self.mapped_tangents(op); } + self.multiple_result(result) } fn on_point(&mut self, point: DVec3) -> CmdResult { + self.pending_tangents = None; + if let Mode::PolyTangent(index) = self.mode { + let Some(entity) = self.target.and_then(|handle| self.entities.get(&handle.value())) else { return CmdResult::NeedPoint; }; + let (normal, elevation, location) = match entity { + EntityType::LwPolyline(p) => { let Some(v) = p.vertices.get(index) else { return CmdResult::NeedPoint; }; (p.normal, p.elevation, [v.location.x, v.location.y]) } + EntityType::Polyline2D(p) => { let Some(v) = p.vertices.get(index) else { return CmdResult::NeedPoint; }; (p.normal, p.elevation, [v.location.x, v.location.y]) } + _ => return CmdResult::NeedPoint, + }; + let Some(local) = crate::entities::curve::ocs_plane(normal, elevation).project([point.x, point.y, point.z]) else { return CmdResult::NeedPoint; }; + let delta = cadkernel::geom2d::Vec2::new(local[0] - location[0], local[1] - location[1]); + if !delta.length_squared().is_finite() || delta.length_squared() <= 1e-24 { return CmdResult::NeedPoint; } + self.vertex_tangents.insert(index, delta.angle().rem_euclid(TAU)); + self.mode = Mode::PolyVertex(index); + return CmdResult::NeedPoint; + } + if let Mode::PolyMove(index) | Mode::PolyInsert(index) = self.mode { + if !point.is_finite() { return CmdResult::NeedPoint; } + let Some(handle) = self.target else { return CmdResult::Cancel; }; + let insert = matches!(self.mode, Mode::PolyInsert(_)); + self.mode = Mode::PolyVertex(index); + let op = PeditOp::EditVertex { index, point, insert }; + self.pending_tangents = self.mapped_tangents(&op); + return CmdResult::PeditOp { handle, op }; + } if let Mode::MeshVertexMove(index) = self.mode { let Some(handle) = self.target else { return CmdResult::Cancel; @@ -507,6 +758,20 @@ impl CadCommand for PeditCommand { let mesh_size = self.mesh_size(); let vertex_default = self.mesh_vertex_default; match &mut self.mode { + Mode::MultipleGather => { + self.target = self.multiple.first().copied(); + if self.target.is_none() { return CmdResult::Cancel; } + self.mode = if self.multiple.iter().any(|handle| self.info.get(&handle.value()).is_some_and(|info| info.convertible)) { + Mode::MultipleConvert + } else { Mode::Options }; + CmdResult::NeedPoint + } + Mode::MultipleConvert => self.on_text_input("Y").unwrap_or(CmdResult::NeedPoint), + Mode::MultipleJoin => self.on_text_input(&self.join_fuzz.to_string()).unwrap_or(CmdResult::NeedPoint), + Mode::PolyWidthStart(_, width) | Mode::PolyWidthEnd(_, width) => { let value = width.to_string(); self.on_text_input(&value).unwrap_or(CmdResult::NeedPoint) } + Mode::PolyVertex(_) | Mode::PolyRange { .. } => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint), + Mode::PolyMove(_) | Mode::PolyInsert(_) => { self.mode = Mode::Options; CmdResult::NeedPoint } + Mode::AwaitLinetype => { self.mode = Mode::Options; CmdResult::NeedPoint } Mode::JoinGather(list) if list.len() >= 2 => CmdResult::JoinEntities(list.clone()), Mode::JoinGather(_) => { self.mode = Mode::Options; @@ -539,11 +804,19 @@ impl CadCommand for PeditCommand { #[derive(Clone)] pub enum PeditOp { + Multiple(Vec, Box), + JoinSelection(Vec, f64), SetClosed(bool), SetWidth(f64), + SetVertexWidth { index: usize, start: f64, end: f64 }, + VertexRange { first: usize, last: usize, split: bool }, + SetLinetypeGeneration(bool), + EditVertex { index: usize, point: DVec3, insert: bool }, + Reverse, /// Replace the picked Line/Arc with an equivalent LwPolyline (#263). ConvertToPolyline, Fit, + FitWithTangents(Vec<(usize, f64)>), Spline, Decurve, SetMeshClosedM(bool), @@ -558,8 +831,117 @@ pub enum PeditOp { // ── Apply logic (pure entity edits; driver handles convert/break/marker) ── +/// Edit complete vertex records; no curve interpolation or geometric splitting +/// is needed because both range boundaries are existing vertices. +pub fn edit_vertex_range(entity: &EntityType, first: usize, last: usize, split: bool) -> Option> { + macro_rules! edit { + ($polyline:expr, $closed:expr, $variant:ident, $set_open:expr) => {{ + let polyline = $polyline; + if first > last || last >= polyline.vertices.len() { return None; } + if split && first == last && (first == 0 || last + 1 == polyline.vertices.len()) { return None; } + if !split { + if first == last { return None; } + let mut result = polyline.clone(); + result.vertices.drain(first + 1..last); + result.vertices[first].bulge = 0.0; + Some(vec![EntityType::$variant(result)]) + } else { + let mut vertices = polyline.vertices.clone(); + if $closed { vertices.push(vertices.first()?.clone()); } + let mut results = Vec::new(); + for part in [&vertices[..=first], &vertices[last..]] { + if part.len() < 2 { continue; } + let mut result = polyline.clone(); + result.vertices = part.to_vec(); + ($set_open)(&mut result); + results.push(EntityType::$variant(result)); + } + (!results.is_empty()).then_some(results) + } + }}; + } + match entity { + EntityType::LwPolyline(polyline) => edit!(polyline, polyline.is_closed, LwPolyline, + |value: &mut acadrust::entities::LwPolyline| { value.is_closed = false; }), + EntityType::Polyline2D(polyline) => edit!(polyline, polyline.flags.is_closed(), Polyline2D, + |value: &mut acadrust::entities::Polyline2D| { value.flags.set_closed(false); }), + _ => None, + } +} + pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool { match op { + PeditOp::Multiple(_, _) | PeditOp::JoinSelection(_, _) | PeditOp::VertexRange { .. } => false, + PeditOp::SetVertexWidth { index, start, end } => { + if !start.is_finite() || !end.is_finite() || *start < 0.0 || *end < 0.0 { return false; } + match entity { + EntityType::LwPolyline(p) => { + if *index >= p.vertices.len() { return false; } + if p.constant_width != 0.0 { + for v in &mut p.vertices { v.start_width = p.constant_width; v.end_width = p.constant_width; } + p.constant_width = 0.0; + } + p.vertices[*index].start_width = *start; p.vertices[*index].end_width = *end; true + } + EntityType::Polyline2D(p) => { + if *index >= p.vertices.len() { return false; } + for v in &mut p.vertices { + if v.start_width == 0.0 { v.start_width = p.start_width; } + if v.end_width == 0.0 { v.end_width = p.end_width; } + } + p.start_width = 0.0; p.end_width = 0.0; + p.vertices[*index].start_width = *start; p.vertices[*index].end_width = *end; true + } + _ => false, + } + } + PeditOp::EditVertex { index, point, insert } => { + if !point.is_finite() { return false; } + match entity { + EntityType::LwPolyline(polyline) => { + let plane = crate::entities::curve::ocs_plane(polyline.normal, polyline.elevation); + let Some(local) = plane.project([point.x, point.y, point.z]) else { return false; }; + if *index >= polyline.vertices.len() { return false; } + if *insert { + polyline.vertices.insert(index + 1, LwVertex::new(Vector2::new(local[0], local[1]))); + } else { polyline.vertices[*index].location = Vector2::new(local[0], local[1]); } + true + } + EntityType::Polyline2D(polyline) => { + let plane = crate::entities::curve::ocs_plane(polyline.normal, polyline.elevation); + let Some(local) = plane.project([point.x, point.y, point.z]) else { return false; }; + if *index >= polyline.vertices.len() { return false; } + let location = Vector3::new(local[0], local[1], polyline.elevation); + if *insert { + polyline.vertices.insert(index + 1, acadrust::entities::polyline::Vertex2D::new(location)); + } else { polyline.vertices[*index].location = location; } + true + } + _ => false, + } + } + PeditOp::Reverse => { + let Some(reversed) = super::reverse::ReverseCommand::reversed(entity) else { return false; }; + *entity = reversed; + true + } + PeditOp::SetLinetypeGeneration(enabled) => match entity { + EntityType::LwPolyline(polyline) => { + if polyline.plinegen == *enabled { return false; } + polyline.plinegen = *enabled; + true + } + EntityType::Polyline2D(polyline) => { + let flag = acadrust::entities::polyline::PolylineFlags::LINETYPE_CONTINUOUS; + let bits = polyline.flags.bits(); + if (bits & flag.bits() != 0) == *enabled { return false; } + polyline.flags = acadrust::entities::polyline::PolylineFlags::from_bits( + if *enabled { bits | flag.bits() } else { bits & !flag.bits() } + ); + true + } + _ => false, + }, PeditOp::SetClosed(closed) => match entity { EntityType::LwPolyline(p) => { p.is_closed = *closed; @@ -575,7 +957,7 @@ pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool { } _ => false, }, - PeditOp::SetWidth(w) => match entity { + PeditOp::SetWidth(w) if w.is_finite() && *w >= 0.0 => match entity { EntityType::LwPolyline(p) => { p.constant_width = *w; for v in &mut p.vertices { @@ -584,25 +966,32 @@ pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool { } true } - _ => false, - }, - PeditOp::Fit => match entity { - EntityType::LwPolyline(p) => fit_curve(p), - _ => false, - }, - PeditOp::Spline => match entity { - EntityType::LwPolyline(p) => spline_smooth(p), - _ => false, - }, - PeditOp::Decurve => match entity { - EntityType::LwPolyline(p) => { - for v in &mut p.vertices { - v.bulge = 0.0; + EntityType::Polyline2D(p) => { + p.start_width = *w; + p.end_width = *w; + for vertex in &mut p.vertices { + vertex.start_width = *w; + vertex.end_width = *w; } true } _ => false, }, + PeditOp::SetWidth(_) => false, + PeditOp::Fit | PeditOp::FitWithTangents(_) => { + let overrides = match op { PeditOp::FitWithTangents(values) => values.as_slice(), _ => &[] }; + let Some(fitted) = fit_entity(entity, overrides) else { return false; }; + *entity = fitted; + true + } + PeditOp::Spline => match entity { + EntityType::LwPolyline(p) => spline_smooth(p), + _ => false, + }, + PeditOp::Decurve => { + let Some(straight) = decurve_entity(entity) else { return false; }; + *entity = straight; true + } PeditOp::SetMeshClosedM(closed) => match entity { EntityType::PolygonMesh(mesh) => { if mesh.is_closed_m() == *closed { @@ -790,139 +1179,98 @@ mod convert_tests { // ── Curve fitting ───────────────────────────────────────────────────────── -fn vert_xy(v: &LwVertex) -> DVec2 { - DVec2::new(v.location.x, v.location.y) -} - -/// Wrap an angle into (-pi, pi]. -fn wrap_angle(mut a: f64) -> f64 { - while a > std::f64::consts::PI { - a -= TAU; - } - while a <= -std::f64::consts::PI { - a += TAU; - } - a -} - -/// Bulge of the arc `a` -> `b` whose tangent AT `a` is `t` (entry form). -fn bulge_entry(a: DVec2, t: DVec2, b: DVec2) -> f64 { - let d = b - a; - if d.length_squared() < 1e-12 { - return 0.0; - } - (wrap_angle(d.y.atan2(d.x) - t.y.atan2(t.x)) / 2.0).tan() -} - -/// Bulge of the arc `a` -> `b` whose tangent AT `b` is `t` (exit form). -fn bulge_exit(a: DVec2, b: DVec2, t: DVec2) -> f64 { - let d = b - a; - if d.length_squared() < 1e-12 { - return 0.0; - } - (wrap_angle(t.y.atan2(t.x) - d.y.atan2(d.x)) / 2.0).tan() -} - -/// PEDIT Fit: replace every segment with a BIARC — two arcs that leave the -/// start vertex along its tangent, meet each other tangentially at an -/// inserted knee vertex, and arrive at the end vertex along ITS tangent. -/// Both segments at a vertex share that vertex's tangent (the average of the -/// neighbouring chords), so the whole run is tangent-continuous — arcs -/// mutually tangent everywhere, which a single arc per segment cannot do. -fn fit_curve(p: &mut acadrust::LwPolyline) -> bool { - let n = p.vertices.len(); - if n < 3 { - return false; - } - let pts: Vec = p.vertices.iter().map(vert_xy).collect(); - let chord = |i: usize| -> DVec2 { - let a = pts[i % n]; - let b = pts[(i + 1) % n]; - (b - a).normalize_or_zero() +fn fit_entity(entity: &EntityType, overrides: &[(usize, f64)]) -> Option { + use acadrust::entities::{Polyline2D, PolylineFlags, Vertex2D, VertexFlags}; + let mut polyline = match entity { + EntityType::Polyline2D(p) => p.clone(), + EntityType::LwPolyline(p) => { + let mut result = Polyline2D::new(); + result.common = p.common.clone(); result.normal = p.normal; + result.elevation = p.elevation; result.thickness = p.thickness; + result.flags = PolylineFlags::from_bits((p.is_closed as u16) | if p.plinegen { 128 } else { 0 }); + result.vertices = p.vertices.iter().map(|v| { + let mut vertex = Vertex2D::new(Vector3::new(v.location.x, v.location.y, p.elevation)); + vertex.start_width = if p.constant_width != 0.0 { p.constant_width } else { v.start_width }; + vertex.end_width = if p.constant_width != 0.0 { p.constant_width } else { v.end_width }; + vertex.id = v.vertex_id; + vertex + }).collect(); + result + } + _ => return None, }; - // Per-vertex tangents. - let tangent = |i: usize| -> DVec2 { - if !p.is_closed && i == 0 { - return chord(0); + // Refit the defining vertices, never the previously inserted fit knees. + let mut originals = defining_vertices(&polyline); + for vertex in &mut originals { + if vertex.start_width == 0.0 { vertex.start_width = polyline.start_width; } + if vertex.end_width == 0.0 { vertex.end_width = polyline.end_width; } + } + if originals.iter().any(|vertex| !vertex.start_width.is_finite() || !vertex.end_width.is_finite() + || vertex.start_width < 0.0 || vertex.end_width < 0.0) { return None; } + for &(index, angle) in overrides { + let vertex = originals.get_mut(index)?; + if !angle.is_finite() { return None; } + vertex.curve_tangent = angle.rem_euclid(TAU); + vertex.flags = VertexFlags::from_bits(vertex.flags.bits() | 2); + } + let points: Vec<_> = originals.iter().map(|v| [v.location.x, v.location.y]).collect(); + let directions: Vec<_> = originals.iter().map(|v| (v.flags.bits() & 2 != 0).then(|| [v.curve_tangent.cos(), v.curve_tangent.sin()])).collect(); + let fitted = cadkernel::geom2d::fit_arc_chain(&points, polyline.flags.is_closed(), &directions)?; + let mut vertices = Vec::with_capacity(fitted.len()); + for (i, fitted_vertex) in fitted.iter().enumerate() { + let original = &originals[fitted_vertex.source]; + let mut vertex = original.clone(); + vertex.location = Vector3::new(fitted_vertex.point[0], fitted_vertex.point[1], polyline.elevation); + vertex.bulge = fitted_vertex.bulge; + let end_fraction = fitted.get(i + 1).filter(|next| next.source == fitted_vertex.source).map_or(1.0, |next| next.fraction); + vertex.start_width = original.start_width + (original.end_width - original.start_width) * fitted_vertex.fraction; + vertex.end_width = original.start_width + (original.end_width - original.start_width) * end_fraction; + if fitted_vertex.inserted { vertex.flags = VertexFlags::EXTRA_VERTEX; vertex.id = 0; } + vertices.push(vertex); + } + polyline.start_width = originals.first()?.start_width; + polyline.end_width = originals.first()?.end_width; + polyline.vertices = vertices; + polyline.flags = PolylineFlags::from_bits((polyline.flags.bits() & !4) | 2); + Some(EntityType::Polyline2D(polyline)) +} + +fn defining_vertices(polyline: &acadrust::entities::Polyline2D) -> Vec { + polyline.vertices.iter().enumerate().filter(|(_, vertex)| vertex.flags.bits() & 1 == 0).map(|(index, vertex)| { + let mut original = vertex.clone(); + if let Some(knee) = polyline.vertices[index + 1..].iter().take_while(|next| next.flags.bits() & 1 != 0).last() { + original.end_width = knee.end_width; } - if !p.is_closed && i == n - 1 { - return chord(n - 2); - } - let prev = chord((i + n - 1) % n); - let cur = chord(i % n); - let sum = prev + cur; - if sum.length_squared() < 1e-12 { - cur - } else { - sum.normalize() + original + }).collect() +} + +fn decurve_entity(entity: &EntityType) -> Option { + let mut result = match entity { + EntityType::LwPolyline(p) => p.clone(), + EntityType::Polyline2D(p) => { + let mut result = acadrust::LwPolyline::new(); + result.common = p.common.clone(); result.normal = p.normal; + result.elevation = p.elevation; result.thickness = p.thickness; + result.is_closed = p.flags.is_closed(); result.plinegen = p.flags.bits() & 128 != 0; + result.vertices = defining_vertices(p).iter().map(|v| { + let mut vertex = LwVertex::new(Vector2::new(v.location.x, v.location.y)); + vertex.vertex_id = v.id; + vertex.start_width = if v.start_width == 0.0 { p.start_width } else { v.start_width }; + vertex.end_width = if v.end_width == 0.0 { p.end_width } else { v.end_width }; + vertex + }).collect(); + result } + _ => return None, }; - let seg_count = if p.is_closed { n } else { n - 1 }; - let mut out: Vec = Vec::with_capacity(seg_count * 2 + 1); - for i in 0..seg_count { - let p0 = pts[i % n]; - let p1 = pts[(i + 1) % n]; - let t0 = tangent(i % n); - let t1 = tangent((i + 1) % n); - let d = p1 - p0; - let src = &p.vertices[i % n]; - let mut push = |q: DVec2, bulge: f64| { - let mut v = LwVertex::new(Vector2::new(q.x, q.y)); - v.bulge = bulge; - v.start_width = src.start_width; - v.end_width = src.end_width; - out.push(v); - }; - if d.length_squared() < 1e-12 { - push(p0, 0.0); - continue; + for vertex in &mut result.vertices { vertex.bulge = 0.0; } + if let Some(width) = result.vertices.first().map(|v| v.start_width) { + if result.vertices.iter().all(|v| v.start_width == width && v.end_width == width) { + result.constant_width = width; } - // Both tangents already aligned with the chord: keep it straight. - let dn = d.normalize(); - if (dn - t0).length_squared() < 1e-12 && (dn - t1).length_squared() < 1e-12 { - push(p0, 0.0); - continue; - } - // Classic equal-parameter biarc: apexes A = p0 + k*t0, B = p1 - k*t1 - // and the knee M at their midpoint. Tangency at M needs |AB| = 2k, - // i.e. 2(1 - t0.t1) k^2 + 2 (d.(t0+t1)) k - |d|^2 = 0 — A and B are - // then the tangent-line apexes of their arcs, so both arcs' tangents - // at M run along AB and the pair is tangent-continuous. - let dot_tt = t0.dot(t1).clamp(-1.0, 1.0); - let b_lin = d.dot(t0 + t1); - let a_quad = 2.0 * (1.0 - dot_tt); - let k = if a_quad.abs() > 1e-9 { - let disc = b_lin * b_lin + a_quad * d.length_squared(); - if disc < 0.0 { - push(p0, bulge_entry(p0, t0, p1)); - continue; - } - (-b_lin + disc.sqrt()) / a_quad - } else if b_lin.abs() > 1e-9 { - // Parallel tangents: the quadratic degenerates to one root. - d.length_squared() / (2.0 * b_lin) - } else { - // Anti-parallel S with no along-tangent reach: symmetric split. - (d.length_squared() / 4.0).sqrt() - }; - if !k.is_finite() || k <= 1e-9 { - push(p0, bulge_entry(p0, t0, p1)); - continue; - } - let m = (p0 + t0 * k + p1 - t1 * k) * 0.5; - push(p0, bulge_entry(p0, t0, m)); - push(m, bulge_exit(m, p1, t1)); } - if !p.is_closed { - let mut last = p.vertices[n - 1].clone(); - last.bulge = 0.0; - out.push(last); - } - if out.len() < 2 { - return false; - } - p.vertices = out; - true + Some(EntityType::LwPolyline(result)) } /// PEDIT Spline: replace the shape with a sampled uniform cubic B-spline of @@ -983,5 +1331,294 @@ fn spline_smooth(p: &mut acadrust::LwPolyline) -> bool { true } +#[cfg(test)] +mod editing_tests { + use super::*; + use acadrust::entities::{Polyline2D, Vertex2D}; + + fn lightweight(handle: u64, points: &[[f64; 2]]) -> EntityType { + let mut polyline = acadrust::LwPolyline::new(); + polyline.common.handle = Handle::new(handle); + polyline.vertices = points + .iter() + .map(|point| LwVertex::new(Vector2::new(point[0], point[1]))) + .collect(); + EntityType::LwPolyline(polyline) + } + + fn command(entities: Vec, preselected: &[Handle]) -> PeditCommand { + let info = entities + .iter() + .map(|entity| { + ( + entity.common().handle.value(), + PeditTarget { + is_poly: matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)), + convertible: matches!(entity, EntityType::Line(_) | EntityType::Arc(_)), + mesh_size: None, + mesh_closed: None, + }, + ) + }) + .collect(); + PeditCommand::new(info, 0, 6, 6) + .with_preselection(preselected) + .with_entities(entities) + } + + #[test] + fn tangent_accepts_a_typed_angle_and_forwards_it_to_fit() { + let handle = Handle::new(1); + let mut command = command( + vec![lightweight(1, &[[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]])], + &[handle], + ); + assert!(matches!(command.on_text_input("E"), Some(CmdResult::NeedPoint))); + assert!(matches!(command.on_text_input("T"), Some(CmdResult::NeedPoint))); + assert!(command.input_kind().wants_text()); + assert!(matches!(command.on_text_input("90"), Some(CmdResult::NeedPoint))); + assert!(matches!(command.on_text_input("X"), Some(CmdResult::NeedPoint))); + + let Some(CmdResult::PeditOp { op: PeditOp::FitWithTangents(values), .. }) = + command.on_text_input("F") + else { + panic!("expected the tangent constraint to be forwarded to Fit"); + }; + assert_eq!(values.len(), 1); + assert_eq!(values[0].0, 0); + assert!((values[0].1 - std::f64::consts::FRAC_PI_2).abs() < 1e-12); + } + + #[test] + fn global_width_applies_to_both_polyline_representations() { + let mut heavy = Polyline2D::new(); + heavy.start_width = 1.0; + heavy.end_width = 2.0; + heavy.vertices = vec![ + Vertex2D::new(Vector3::new(0.0, 0.0, 0.0)), + Vertex2D::new(Vector3::new(1.0, 0.0, 0.0)), + ]; + let mut entity = EntityType::Polyline2D(heavy); + assert!(apply_pedit(&mut entity, &PeditOp::SetWidth(3.5))); + let EntityType::Polyline2D(polyline) = &entity else { unreachable!() }; + assert_eq!((polyline.start_width, polyline.end_width), (3.5, 3.5)); + assert!(polyline + .vertices + .iter() + .all(|vertex| vertex.start_width == 3.5 && vertex.end_width == 3.5)); + assert!(!apply_pedit(&mut entity, &PeditOp::SetWidth(f64::NAN))); + } + + #[test] + fn vertex_ranges_preserve_complete_endpoint_records() { + let mut entity = lightweight( + 7, + &[[0.0, 0.0], [1.0, 0.0], [2.0, 1.0], [3.0, 1.0], [4.0, 0.0]], + ); + let EntityType::LwPolyline(polyline) = &mut entity else { unreachable!() }; + for (index, vertex) in polyline.vertices.iter_mut().enumerate() { + vertex.bulge = index as f64 / 10.0; + vertex.start_width = index as f64 + 0.25; + vertex.end_width = index as f64 + 0.75; + } + + let split = edit_vertex_range(&entity, 1, 3, true).unwrap(); + assert_eq!(split.len(), 2); + let EntityType::LwPolyline(first) = &split[0] else { unreachable!() }; + let EntityType::LwPolyline(second) = &split[1] else { unreachable!() }; + assert_eq!(first.vertices.len(), 2); + assert_eq!(second.vertices.len(), 2); + assert_eq!(first.vertices[1].end_width, 1.75); + assert_eq!(second.vertices[0].bulge, 0.3); + assert_eq!(second.vertices[0].start_width, 3.25); + + let straightened = edit_vertex_range(&entity, 1, 3, false).unwrap(); + let EntityType::LwPolyline(straightened) = &straightened[0] else { unreachable!() }; + assert_eq!(straightened.vertices.len(), 4); + assert_eq!(straightened.vertices[1].bulge, 0.0); + assert_eq!(straightened.vertices[1].start_width, 1.25); + assert_eq!(straightened.vertices[1].end_width, 1.75); + assert!(edit_vertex_range(&entity, 0, 0, true).is_none()); + } + + #[test] + fn fit_and_decurve_preserve_plane_identity_and_uniform_width() { + let mut entity = lightweight(9, &[[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]]); + let EntityType::LwPolyline(polyline) = &mut entity else { unreachable!() }; + polyline.constant_width = 2.0; + polyline.elevation = 4.0; + polyline.thickness = -1.5; + polyline.normal = Vector3::new(0.0, 0.0, 1.0); + + assert!(apply_pedit( + &mut entity, + &PeditOp::FitWithTangents(vec![(0, 0.0)]) + )); + let EntityType::Polyline2D(fitted) = &entity else { panic!("Fit must use the legacy fitted representation") }; + assert_eq!(fitted.common.handle, Handle::new(9)); + assert_eq!(fitted.elevation, 4.0); + assert_eq!(fitted.thickness, -1.5); + assert!(fitted.vertices.len() > 3); + assert!(fitted.vertices[0].flags.bits() & 2 != 0); + + assert!(apply_pedit(&mut entity, &PeditOp::Decurve)); + let EntityType::LwPolyline(straight) = &entity else { panic!("Decurve must restore a lightweight polyline") }; + assert_eq!(straight.common.handle, Handle::new(9)); + assert_eq!(straight.constant_width, 2.0); + assert_eq!(straight.vertices.len(), 3); + assert!(straight.vertices.iter().all(|vertex| vertex.bulge == 0.0)); + } + + #[test] + fn multiple_decline_and_fuzz_join_keep_the_command_active() { + let mut line = acadrust::entities::Line::new(); + line.common.handle = Handle::new(11); + let mut arc = acadrust::entities::Arc::new(); + arc.common.handle = Handle::new(12); + let mut declined = command( + vec![EntityType::Line(line), EntityType::Arc(arc)], + &[Handle::new(11), Handle::new(12)], + ); + assert!(matches!(declined.on_text_input("N"), Some(CmdResult::NeedPoint))); + assert!(declined.needs_entity_pick()); + + let handles = [Handle::new(21), Handle::new(22)]; + let mut joined = command( + vec![ + lightweight(21, &[[0.0, 0.0], [1.0, 0.0]]), + lightweight(22, &[[1.0, 0.0], [2.0, 0.0]]), + ], + &handles, + ); + assert!(matches!(joined.on_text_input("J"), Some(CmdResult::NeedPoint))); + let Some(CmdResult::PeditOp { + op: PeditOp::JoinSelection(selected, fuzz), + .. + }) = joined.on_text_input("0.25") + else { + panic!("expected a source-directed multiple join"); + }; + assert_eq!(selected, handles); + assert_eq!(fuzz, 0.25); + + let mut remembered = command( + vec![ + lightweight(21, &[[0.0, 0.0], [1.0, 0.0]]), + lightweight(22, &[[1.0, 0.0], [2.0, 0.0]]), + ], + &handles, + ); + assert!(matches!( + remembered.on_text_input("J"), + Some(CmdResult::NeedPoint) + )); + assert!(remembered.prompt().contains("<0.25>")); + *JOIN_FUZZ.lock().unwrap_or_else(|error| error.into_inner()) = 0.0; + } + + #[test] + fn join_fuzz_extends_only_supported_straight_terminals_within_the_limit() { + let source = lightweight(31, &[[0.0, 0.0], [1.0, 0.0]]); + let candidate = lightweight(32, &[[2.0, 2.0], [2.0, 1.0]]); + let candidates = [(Handle::new(32), &candidate)]; + + assert!(join_selection_extend(&source, &candidates, 0.99).is_none()); + let (joined, consumed) = join_selection_extend(&source, &candidates, 1.0).unwrap(); + assert_eq!(consumed, vec![Handle::new(32)]); + let EntityType::LwPolyline(joined) = joined else { + panic!("expected a lightweight polyline"); + }; + assert_eq!(joined.common.handle, Handle::new(31)); + let points: Vec<_> = joined + .vertices + .iter() + .map(|vertex| [vertex.location.x, vertex.location.y]) + .collect(); + assert_eq!(points, vec![[0.0, 0.0], [2.0, 0.0], [2.0, 2.0]]); + + let mut curved = source.clone(); + let EntityType::LwPolyline(polyline) = &mut curved else { + unreachable!() + }; + polyline.vertices[0].bulge = 0.25; + assert!(join_selection_extend(&curved, &candidates, 2.0).is_none()); + + let parallel = lightweight(33, &[[2.0, 1.0], [1.0, 1.0]]); + assert!(join_selection_extend( + &source, + &[(Handle::new(33), ¶llel)], + 2.0, + ) + .is_none()); + } +} + // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["PEDIT"] }); // PeditCommand + + +/// Extend straight terminal spans before reusing the ordinary source join. +/// Curved terminal spans and unsupported representations remain unchanged. +pub fn join_selection_extend(source: &EntityType, candidates: &[(Handle, &EntityType)], fuzz: f64) -> Option<(EntityType, Vec)> { + fn endpoint(entity: &EntityType, start: bool) -> Option<[[f64; 3]; 2]> { + let (points, normal, elevation) = match entity { + EntityType::LwPolyline(p) if !p.is_closed && p.vertices.len() >= 2 => { + let n = p.vertices.len(); let segment = if start {0} else {n-2}; + if p.vertices[segment].bulge != 0.0 { return None; } + let indices = if start {[1,0]} else {[n-2,n-1]}; + (indices.map(|i| [p.vertices[i].location.x,p.vertices[i].location.y]),p.normal.clone(),p.elevation) + } + EntityType::Polyline2D(p) if !p.is_closed() && p.vertices.len() >= 2 => { + let n = p.vertices.len(); let segment = if start {0} else {n-2}; + if p.vertices[segment].bulge != 0.0 { return None; } + let indices = if start {[1,0]} else {[n-2,n-1]}; + (indices.map(|i| [p.vertices[i].location.x,p.vertices[i].location.y]),p.normal.clone(),p.elevation) + } + _ => return None, + }; + let plane = crate::entities::curve::ocs_plane(normal,elevation); + Some(points.map(|point| plane.point_at(point))) + } + fn move_endpoint(entity: &mut EntityType, start: bool, point: [f64;3]) -> Option<()> { + match entity { + EntityType::LwPolyline(p) => { + let local = crate::entities::curve::ocs_plane(p.normal.clone(),p.elevation).project(point)?; + let index = if start {0} else {p.vertices.len().checked_sub(1)?}; + p.vertices[index].location = Vector2::new(local[0],local[1]); + } + EntityType::Polyline2D(p) => { + let local = crate::entities::curve::ocs_plane(p.normal.clone(),p.elevation).project(point)?; + let index = if start {0} else {p.vertices.len().checked_sub(1)?}; + p.vertices[index].location.x = local[0]; p.vertices[index].location.y = local[1]; + } + _ => return None, + } + Some(()) + } + let mut result = source.clone(); let mut consumed = Vec::new(); + loop { + let mut progress = false; + for (handle,candidate) in candidates { + if consumed.contains(handle) {continue;} + let mut joined = super::join::join_to_source(&result,&[(*handle,*candidate)]).map(|(entity,_)|entity); + if joined.is_none() && fuzz > 0.0 && fuzz.is_finite() { + 'ends: for a_start in [false,true] { for b_start in [true,false] { + let Some(a) = endpoint(&result,a_start) else {continue;}; + let Some(b) = endpoint(candidate,b_start) else {continue;}; + let Some(point) = cadkernel::space::endpoint_join::extend_line_ends(a,b,fuzz) else {continue;}; + let mut a = result.clone(); let mut b = (*candidate).clone(); + if move_endpoint(&mut a,a_start,point).is_none() || move_endpoint(&mut b,b_start,point).is_none() {continue;} + if let Some((entity,_)) = super::join::join_to_source(&a,&[(*handle,&b)]) { + joined = Some(entity); break 'ends; + } + }} + } + if let Some(mut entity) = joined { + *entity.common_mut() = source.common().clone(); + result = entity; consumed.push(*handle); progress = true; + } + } + if !progress {break;} + } + (!consumed.is_empty()).then_some((result,consumed)) +} diff --git a/src/modules/draw/modify/reverse.rs b/src/modules/draw/modify/reverse.rs index 3d7e3d51e..5fa5d6eca 100644 --- a/src/modules/draw/modify/reverse.rs +++ b/src/modules/draw/modify/reverse.rs @@ -20,10 +20,10 @@ // Any other entity type is left untouched: the command returns // `CmdResult::NeedPoint` and keeps prompting so nothing is corrupted. +use crate::t; use acadrust::entities::Spline; use acadrust::{EntityType, Handle}; use glam::DVec3; -use crate::t; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -54,7 +54,7 @@ impl ReverseCommand { } /// Build a reversed copy of `entity`, or `None` for an unsupported type. - fn reversed(entity: &EntityType) -> Option { + pub(crate) fn reversed(entity: &EntityType) -> Option { match entity { EntityType::Line(line) => { let mut out = line.clone(); @@ -62,9 +62,7 @@ impl ReverseCommand { Some(EntityType::Line(out)) } EntityType::LwPolyline(pl) => Some(EntityType::LwPolyline(reverse_lwpolyline(pl))), - EntityType::Polyline2D(pl) => { - Some(EntityType::Polyline2D(reverse_polyline2d(pl))) - } + EntityType::Polyline2D(pl) => Some(EntityType::Polyline2D(reverse_polyline2d(pl))), EntityType::Polyline(pl) => { let mut out = pl.clone(); out.vertices.reverse(); @@ -76,6 +74,9 @@ impl ReverseCommand { Some(EntityType::Polyline3D(out)) } EntityType::Spline(sp) => Some(EntityType::Spline(reverse_spline(sp))), + EntityType::Helix(helix) => { + crate::entities::helix::reversed(helix).map(EntityType::Helix) + } _ => None, } } @@ -153,8 +154,8 @@ fn reverse_polyline2d(pl: &acadrust::entities::Polyline2D) -> acadrust::entities for vertex in &mut out.vertices { let tangent_flag = acadrust::entities::VertexFlags::CURVE_FIT_TANGENT.bits(); if vertex.flags.bits() & tangent_flag != 0 { - vertex.curve_tangent = (vertex.curve_tangent + std::f64::consts::PI) - .rem_euclid(std::f64::consts::TAU); + vertex.curve_tangent = + (vertex.curve_tangent + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU); } } if pl.is_closed() { @@ -176,9 +177,8 @@ fn reverse_polyline2d(pl: &acadrust::entities::Polyline2D) -> acadrust::entities out } -/// Reverse a Spline: flip control points and fit points, regenerate the -/// clamped knot vector. Mirrors `splinedit::apply_spline_op`'s REVERSE branch. -fn reverse_spline(sp: &Spline) -> Spline { +/// Reverse stored coordinates in 3D and let the kernel mirror the knot domain. +pub(super) fn reverse_spline(sp: &Spline) -> Spline { let mut out = sp.clone(); out.control_points.reverse(); out.fit_points.reverse(); @@ -186,8 +186,16 @@ fn reverse_spline(sp: &Spline) -> Spline { if out.weights.len() == out.control_points.len() { out.weights.reverse(); } - out.knots = - Spline::generate_clamped_knots(out.degree as usize, out.control_points.len()); + if let Some(curve) = super::spline_ops::spline_to_nurbs(sp) { + out.knots = curve.reversed().knots().to_vec(); + } + out.begin_tangent = + acadrust::types::Vector3::new(-sp.end_tangent.x, -sp.end_tangent.y, -sp.end_tangent.z); + out.end_tangent = acadrust::types::Vector3::new( + -sp.begin_tangent.x, + -sp.begin_tangent.y, + -sp.begin_tangent.z, + ); out } @@ -197,7 +205,7 @@ impl CadCommand for ReverseCommand { } fn prompt(&self) -> String { - t!("REVERSE Select line, polyline or spline to reverse:").into_owned() + t!("REVERSE Select line, polyline, spline or helix to reverse:").into_owned() } fn needs_entity_pick(&self) -> bool { @@ -240,3 +248,47 @@ impl CadCommand for ReverseCommand { inventory::submit!(crate::command::CommandRegistration { names: &["REVERSE"] }); // ReverseCommand + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::types::Vector3; + + #[test] + fn spline_reversal_preserves_knot_spacing_weights_and_tangents() { + let mut spline = Spline::new(); + spline.degree = 2; + spline.control_points = vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 2.0, 0.0), + Vector3::new(3.0, 1.0, 1.0), + Vector3::new(4.0, 0.0, 2.0), + ]; + spline.knots = vec![0.0, 0.0, 0.0, 0.25, 1.0, 1.0, 1.0]; + spline.weights = vec![1.0, 2.0, 3.0, 4.0]; + spline.begin_tangent = Vector3::new(1.0, 2.0, 3.0); + spline.end_tangent = Vector3::new(4.0, 5.0, 6.0); + + let reversed = reverse_spline(&spline); + assert_eq!( + reversed.control_points, + spline + .control_points + .iter() + .rev() + .cloned() + .collect::>() + ); + assert_eq!(reversed.weights, vec![4.0, 3.0, 2.0, 1.0]); + assert_eq!(reversed.knots, vec![0.0, 0.0, 0.0, 0.75, 1.0, 1.0, 1.0]); + assert_eq!(reversed.begin_tangent, Vector3::new(-4.0, -5.0, -6.0)); + assert_eq!(reversed.end_tangent, Vector3::new(-1.0, -2.0, -3.0)); + + let restored = reverse_spline(&reversed); + assert_eq!(restored.control_points, spline.control_points); + assert_eq!(restored.weights, spline.weights); + assert_eq!(restored.knots, spline.knots); + assert_eq!(restored.begin_tangent, spline.begin_tangent); + assert_eq!(restored.end_tangent, spline.end_tangent); + } +} diff --git a/src/modules/draw/modify/setbylayer.rs b/src/modules/draw/modify/setbylayer.rs new file mode 100644 index 000000000..d266ab542 --- /dev/null +++ b/src/modules/draw/modify/setbylayer.rs @@ -0,0 +1,153 @@ +use acadrust::Handle; +use glam::DVec3; +use crate::command::{CadCommand, CmdOption, CmdResult}; + +static MODE: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(255); +pub fn mode() -> u8 { MODE.load(std::sync::atomic::Ordering::Relaxed) } +pub fn set_mode(value: u8) { MODE.store(value, std::sync::atomic::Ordering::Relaxed); } + +pub fn apply_mask(common: &mut acadrust::entities::EntityCommon, mask: u8, change_byblock: bool) -> bool { + let before = common.clone(); + if mask & 1 != 0 && (change_byblock || common.color != acadrust::types::Color::ByBlock) { + common.color = acadrust::types::Color::ByLayer; + common.color_name = None; + common.color_book_handle = None; + } + if mask & 2 != 0 && (change_byblock || !common.linetype.eq_ignore_ascii_case("ByBlock")) { + common.linetype = "ByLayer".to_string(); + common.linetype_handle = None; + } + if mask & 4 != 0 && (change_byblock || common.line_weight != acadrust::types::LineWeight::ByBlock) { + common.line_weight = acadrust::types::LineWeight::ByLayer; + } + if mask & 8 != 0 && (change_byblock || common.material_flags != 1) { + common.material_flags = 0; + common.material_handle = None; + } + if mask & 16 != 0 && (change_byblock || common.plotstyle_flags != 1) { + common.plotstyle_flags = 0; + common.plotstyle_handle = None; + } + if mask & 128 != 0 && (change_byblock || common.transparency != acadrust::types::Transparency::BY_BLOCK) { + common.transparency = acadrust::types::Transparency::BY_LAYER; + } + *common != before +} + +enum Step { Selection, ByBlock, Blocks } + +pub struct SetByLayerCommand { + selected: Vec, + step: Step, + change_byblock: bool, + include_blocks: bool, +} + +impl SetByLayerCommand { + pub fn new(selected: Vec) -> Self { + let step = if selected.is_empty() { Step::Selection } else { Step::ByBlock }; + Self { selected, step, change_byblock: mode() & 32 != 0, include_blocks: mode() & 64 != 0 } + } + + fn answer(&mut self, yes: bool) -> CmdResult { + match self.step { + Step::ByBlock => { + self.change_byblock = yes; + self.step = Step::Blocks; + CmdResult::NeedPoint + } + Step::Blocks => { + let flags = (mode() & !(32 | 64)) | if self.change_byblock { 32 } else { 0 } | if yes { 64 } else { 0 }; + set_mode(flags); + CmdResult::Relaunch( + format!("SETBYLAYER_APPLY {} {}", u8::from(self.change_byblock), u8::from(yes)), + self.selected.clone(), + ) }, + Step::Selection => CmdResult::NeedPoint, + } + } +} + +impl CadCommand for SetByLayerCommand { + fn name(&self) -> &'static str { "SETBYLAYER" } + fn prompt(&self) -> String { + match self.step { + Step::Selection => "Select objects:".into(), + Step::ByBlock => format!("Change ByBlock to ByLayer? [Yes/No] <{}>:", if self.change_byblock { "Yes" } else { "No" }), + Step::Blocks => format!("Include blocks? [Yes/No] <{}>:", if self.include_blocks { "Yes" } else { "No" }), + } + } + fn is_selection_gathering(&self) -> bool { matches!(self.step, Step::Selection) } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.selected = handles; + CmdResult::NeedPoint + } + fn options(&self) -> Vec { + if self.is_selection_gathering() { Vec::new() } + else { vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")] } + } + fn wants_text_input(&self) -> bool { !self.is_selection_gathering() } + fn on_point(&mut self, _: DVec3) -> CmdResult { CmdResult::NeedPoint } + fn on_enter(&mut self) -> CmdResult { + if self.is_selection_gathering() { + if self.selected.is_empty() { CmdResult::Cancel } + else { self.step = Step::ByBlock; CmdResult::NeedPoint } + } else { self.answer(if matches!(self.step, Step::ByBlock) { self.change_byblock } else { self.include_blocks }) } + } + fn on_text_input(&mut self, text: &str) -> Option { + Some(match text.trim().to_uppercase().as_str() { + "Y" | "YES" => self.answer(true), + "N" | "NO" => self.answer(false), + _ => CmdResult::NeedPoint, + }) + } +} + +pub struct ModeCommand; +impl CadCommand for ModeCommand { + fn name(&self) -> &'static str { "SETBYLAYERMODE" } + fn prompt(&self) -> String { format!("Enter new value for SETBYLAYERMODE <{}>:", mode()) } + fn wants_text_input(&self) -> bool { true } + fn on_point(&mut self, _: DVec3) -> CmdResult { CmdResult::NeedPoint } + fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } + fn on_text_input(&mut self, text: &str) -> Option { + Some(if let Ok(value) = text.trim().parse::() { + set_mode(value); CmdResult::Cancel + } else { CmdResult::NeedPoint }) + } +} +inventory::submit!(crate::command::CommandRegistration { names: &["SETBYLAYERMODE"] }); + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::EntityCommon; + use acadrust::types::{Color, Transparency}; + + #[test] + fn mask_changes_only_requested_properties() { + let mut common = EntityCommon::default(); + common.color = Color::from_index(3); + common.linetype = "DASHED".into(); + common.transparency = Transparency::from_percent(40.0); + + assert!(apply_mask(&mut common, 1 | 128, false)); + assert_eq!(common.color, Color::ByLayer); + assert_eq!(common.linetype, "DASHED"); + assert_eq!(common.transparency, Transparency::BY_LAYER); + } + + #[test] + fn byblock_values_are_kept_unless_requested() { + let mut common = EntityCommon::default(); + common.color = Color::ByBlock; + common.linetype = "ByBlock".into(); + common.transparency = Transparency::BY_BLOCK; + + assert!(!apply_mask(&mut common, 1 | 2 | 128, false)); + assert!(apply_mask(&mut common, 1 | 2 | 128, true)); + assert_eq!(common.color, Color::ByLayer); + assert_eq!(common.linetype, "ByLayer"); + assert_eq!(common.transparency, Transparency::BY_LAYER); + } +} diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 6a5852f73..6664f3889 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -13,7 +13,6 @@ use acadrust::types::Vector3; use acadrust::EntityType; use glam::DVec3; -use crate::modules::draw::modify::spline_ops::spline_to_nurbs; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -29,209 +28,765 @@ pub fn tool() -> ToolDef { } } +#[derive(Clone, Copy)] enum Step { - /// Waiting for the user to pick a spline entity. SelectSpline, - /// Spline selected; waiting for sub-command text input. - SubCommand { handle: acadrust::Handle }, + Options, + Refine, + Join, + PolylinePrecision, + Add, + Delete, + Elevate, + Move { index: usize, refine: bool }, + Weight { index: usize }, + SelectVertex { weight: bool, refine: bool }, } pub struct SplineditCommand { step: Step, + handle: acadrust::Handle, + spline: Option, + pending: Option, + history: Vec<(acadrust::Handle, acadrust::entities::Spline)>, + pick_context: Option, + join_candidates: Vec, + delete_source: bool, +} + +fn clear_fit_method(spline: &mut acadrust::entities::Spline) { + spline.fit_points.clear(); + spline.begin_tangent = Vector3::ZERO; + spline.end_tangent = Vector3::ZERO; + spline.dwg_flags1 &= !1; + spline.dxf_flags &= !(32 | 1024); +} + +fn weights_are_rational(weights: &[f64]) -> bool { + weights.first().is_some_and(|first| { + weights + .iter() + .any(|weight| (*weight - *first).abs() > 1e-12) + }) } impl SplineditCommand { pub fn new() -> Self { - Self { - step: Step::SelectSpline, + Self { step: Step::SelectSpline, handle: acadrust::Handle::NULL, spline: None, pending: None, history: Vec::new(), pick_context: None, join_candidates: Vec::new(), delete_source: true } + } + + pub fn with_delete_source(mut self, delete: bool) -> Self { self.delete_source = delete; self } + + fn convert_polyline(&mut self, precision: u8) -> CmdResult { + let result = self.spline.as_ref().and_then(|source| { + let curve = spatial_spline(source)?; + let approximation = curve.to_polyline_precision(precision)?; + let mut points = approximation.points; + if self.closed() { + if points.first() != points.last() { return None; } + points.pop(); + } + if points.len() < 2 { return None; } + let entity = if let Some(planar) = crate::entities::curve::entity_curve(&EntityType::Spline(source.clone())) { + let normal = planar.plane.normal()?; + let elevation = cadkernel::space::Vec3::from(points[0]).dot(cadkernel::space::Vec3::from(normal)); + let normal = Vector3::new(normal[0], normal[1], normal[2]); + let plane = crate::entities::curve::ocs_plane(normal.clone(), elevation); + let mut polyline = acadrust::LwPolyline::new(); + polyline.common = source.common.clone(); + polyline.elevation = elevation; polyline.normal = normal; polyline.is_closed = self.closed(); + polyline.vertices = points.iter().map(|point| { + let uv = plane.project(*point)?; + Some(acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(uv[0], uv[1]))) + }).collect::>>()?; + EntityType::LwPolyline(polyline) + } else { + let mut polyline = acadrust::entities::Polyline3D::from_points(points.iter().map(|p| Vector3::new(p[0],p[1],p[2])).collect()); + polyline.common = source.common.clone(); polyline.flags.closed = self.closed(); + EntityType::Polyline3D(polyline) + }; + Some(entity) + }); + let Some(mut entity) = result else { return CmdResult::ReportError(crate::t!("Spline cannot be converted within the requested precision.").into_owned()); }; + if self.delete_source { CmdResult::ReplaceMany(vec![(self.handle,vec![entity])], Vec::new()) } + else { entity.common_mut().handle = acadrust::Handle::NULL; CmdResult::ReplaceMany(Vec::new(), vec![entity]) } + } + + fn picked_vertex(&self, point: DVec3) -> Option { + let context = self.pick_context?; + let project = |point: DVec3| { + let clip = context.view * (point - context.eye).as_vec3().extend(1.0); + if !clip.is_finite() || clip.w <= 0.0 { return None; } + let screen = crate::scene::pick::hit_test::world_to_screen( + point, context.view, context.eye, context.bounds); + (screen.x.is_finite() && screen.y.is_finite() + && screen.x >= 0.0 && screen.x <= context.bounds.width + && screen.y >= 0.0 && screen.y <= context.bounds.height).then_some(screen) + }; + let cursor = project(point)?; + self.spline.as_ref()?.control_points.iter().enumerate().filter_map(|(index, vertex)| { + let screen = project(DVec3::new(vertex.x, vertex.y, vertex.z))?; + let distance = (screen.x - cursor.x).hypot(screen.y - cursor.y); + (distance.is_finite() && distance <= context.aperture_px).then_some((index, distance)) + }).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(index, _)| index) + } + + fn closed(&self) -> bool { + self.spline.as_ref().is_some_and(|spline| spline.flags.closed || spline.flags.periodic) + } + + fn replace(&mut self, spline: acadrust::entities::Spline) -> CmdResult { + self.pending = Some(spline.clone()); + CmdResult::ReplaceManyContinue(vec![(self.handle, vec![EntityType::Spline(spline)])]) + } + + fn finish_join(&mut self) -> CmdResult { + self.step = Step::Options; + let candidates = std::mem::take(&mut self.join_candidates); + let Some(source) = self.spline.as_ref() else { return CmdResult::NeedPoint; }; + let selected: Vec<_> = candidates.iter().filter(|item| item.handle != self.handle) + .map(|item| (item.handle, &item.entity)).collect(); + let Some((EntityType::Spline(mut spline), consumed)) = super::join::join_to_source( + &EntityType::Spline(source.clone()), &selected) else { return CmdResult::NeedPoint; }; + spline.dwg_flags1 &= !1; + spline.dxf_flags &= !(32 | 1024); + self.pending = Some(spline.clone()); + let mut replacements = vec![(self.handle, vec![EntityType::Spline(spline)])]; + replacements.extend(consumed.into_iter().map(|handle| (handle, Vec::new()))); + // The host stores one document snapshot, including every consumed curve. + // The existing command-local Undo restores that snapshot and the source cache. + CmdResult::ReplaceManyContinue(replacements) + } + + fn refined(&self, point: Option, degree: Option) -> Option { + let source = self.spline.as_ref()?; + if let Some(degree) = degree { + let current = usize::try_from(source.degree).ok()?; + if degree <= current || degree > 25 { return None; } + let curve = spatial_spline(source)?; + let elevation = degree.checked_sub(curve.degree())?; + let curve = if elevation == 0 { curve } else { curve.elevated(elevation)? } + .compact_knots(source.control_tolerance.max(1e-9))?; + let mut result = source.clone(); + result.degree = curve.degree() as i32; + result.control_points = curve.control_points().iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect(); + result.knots = curve.knots().to_vec(); + result.weights = curve.weights().to_vec(); + clear_fit_method(&mut result); + result.flags.rational = curve.is_rational(); + result.flags.planar = crate::entities::curve::spline_is_planar(&result); + return Some(result); } + let planar = crate::entities::curve::entity_curve(&EntityType::Spline(source.clone()))?; + let cadkernel::geom2d::Curve::Nurbs(mut curve) = planar.curve else { return None; }; + if let Some(point) = point { + let projected = planar.plane.project([point.x, point.y, point.z])?; + let nearest = cadkernel::geom2d::closest_point(&cadkernel::geom2d::Curve::Nurbs(curve.clone()), projected); + let (start, end) = curve.domain(); + let parameter = start + nearest.t * (end - start); + if parameter <= start || parameter >= end { return None; } + curve.insert_knot(parameter); + } + let mut result = crate::modules::draw::modify::spline_ops::nurbs_to_spline(&curve, source); + result.control_points = curve.control_points().iter().map(|point| { + let world = planar.plane.point_at(*point); + Vector3::new(world[0], world[1], world[2]) + }).collect(); + clear_fit_method(&mut result); + result.flags.rational = curve.is_rational(); + result.flags.planar = crate::entities::curve::spline_is_planar(&result); + Some(result) } } impl CadCommand for SplineditCommand { - fn name(&self) -> &'static str { - "SPLINEDIT" - } - + fn name(&self) -> &'static str { "SPLINEDIT" } fn prompt(&self) -> String { - match &self.step { + match self.step { Step::SelectSpline => crate::t!("SPLINEDIT Select spline:").into_owned(), - Step::SubCommand { .. } => crate::t!("SPLINEDIT [CLOSE/OPEN/REVERSE/EXIT]:").into_owned(), + Step::Options if self.closed() => crate::t!("SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] :").into_owned(), + Step::Options => crate::t!("SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] :").into_owned(), + Step::PolylinePrecision => crate::t!("SPLINEDIT Specify precision 0-99 <10> (straight segments):").into_owned(), + Step::Join => crate::t!("SPLINEDIT Select any open curves to join to source:").into_owned(), + Step::Refine => crate::t!("SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] :").into_owned(), + Step::Add => crate::t!("SPLINEDIT Specify a point on the spline :").into_owned(), + Step::Delete => crate::t!("SPLINEDIT Specify control vertex to delete:").into_owned(), + Step::Elevate => format!("SPLINEDIT Enter new order <{}>:", self.spline.as_ref().map_or(4, |s| s.degree + 1)), + Step::Move { index, .. } => format!("SPLINEDIT Vertex {}: specify new location or [Next/Previous/Select point/eXit] :", index + 1), + Step::SelectVertex { .. } => crate::t!("SPLINEDIT Specify control vertex:").into_owned(), + Step::Weight { index } => format!("SPLINEDIT Vertex {}: enter new weight or [Next/Previous/Select point/eXit] :", index + 1), } } - - fn needs_entity_pick(&self) -> bool { - matches!(self.step, Step::SelectSpline) + fn options(&self) -> Vec { + use crate::command::CmdOption; + match self.step { + Step::Options => { + let mut options = vec![if self.closed() { CmdOption::new("Open", "O") } else { CmdOption::new("Close", "C") }]; + if !self.closed() { options.push(CmdOption::new("Join", "J")); } + options.extend([CmdOption::new("Move vertex", "M"), CmdOption::new("Refine", "R"), CmdOption::new("Reverse", "E"), CmdOption::new("Polyline (lines)", "P"), CmdOption::new("Undo", "U"), CmdOption::new("Exit", "X")]); + options + }, + Step::Refine => vec![CmdOption::new("Add", "A"), CmdOption::new("Delete", "D"), CmdOption::new("Elevate order", "E"), CmdOption::new("Move", "M"), CmdOption::new("Weight", "W"), CmdOption::new("Exit", "X")], + Step::Move { .. } | Step::Weight { .. } => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Select point", "S"), CmdOption::new("Exit", "X")], + _ => Vec::new(), + } + } + fn is_selection_gathering(&self) -> bool { matches!(self.step, Step::Join) } + fn selection_entities_exclude_locked(&self) -> bool { matches!(self.step, Step::Join) } + fn inject_selection_entities(&mut self, entities: Vec) { + if matches!(self.step, Step::Join) { self.join_candidates = entities; } + } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.join_candidates.retain(|item| handles.contains(&item.handle) && item.handle != self.handle); + CmdResult::NeedPoint + } + fn needs_entity_pick(&self) -> bool { matches!(self.step, Step::SelectSpline) } + fn inject_before_entity_pick(&self) -> bool { true } + fn inject_picked_entity(&mut self, entity: EntityType) { + self.spline = match entity { EntityType::Spline(spline) => Some(spline), _ => None }; } - fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: DVec3) -> CmdResult { - if handle.is_null() { - return CmdResult::NeedPoint; - } - self.step = Step::SubCommand { handle }; + if handle.is_null() || self.spline.is_none() { return CmdResult::NeedPoint; } + self.handle = handle; + self.step = Step::Options; CmdResult::NeedPoint } - - fn wants_text_input(&self) -> bool { - matches!(self.step, Step::SubCommand { .. }) + fn on_entity_replaced(&mut self, old: acadrust::Handle, new: &[acadrust::Handle]) { + if old == self.handle { + if let (Some(&handle), Some(replacement)) = (new.first(), self.pending.take()) { + if let Some(previous) = self.spline.replace(replacement) { self.history.push((old, previous)); } + self.handle = handle; + } + } } - + fn wants_text_input(&self) -> bool { !matches!(self.step, Step::SelectSpline | Step::Join | Step::Add | Step::Delete | Step::Move { .. } | Step::SelectVertex { .. }) } fn on_text_input(&mut self, text: &str) -> Option { - let Step::SubCommand { handle } = &self.step else { - return None; - }; - let handle = *handle; - match text.trim().to_uppercase().as_str() { - "CLOSE" | "C" => Some(CmdResult::ReplaceEntity( - handle, - vec![SplineOp::Close.into_marker(handle)], - )), - "OPEN" | "O" => Some(CmdResult::ReplaceEntity( - handle, - vec![SplineOp::Open.into_marker(handle)], - )), - "REVERSE" | "R" | "REV" => Some(CmdResult::ReplaceEntity( - handle, - vec![SplineOp::Reverse.into_marker(handle)], - )), - "EXIT" | "X" | "" => Some(CmdResult::Cancel), - _ => None, + let upper = text.trim().to_uppercase(); + if upper.is_empty() { return Some(self.on_enter()); } + match self.step { + Step::Options => match upper.as_str() { + "P" | "POLYLINE" => self.step = Step::PolylinePrecision, + "J" | "JOIN" if !self.closed() => { self.join_candidates.clear(); self.step = Step::Join; }, + "R" | "REFINE" => self.step = Step::Refine, + "M" | "MOVE" => self.step = Step::Move { index: 0, refine: false }, + "X" | "EXIT" => return Some(CmdResult::Cancel), + "U" | "UNDO" => { + if let Some((handle, spline)) = self.history.pop() { + self.handle = handle; + self.spline = Some(spline); + return Some(CmdResult::UndoDocument); + } + } + "C" | "CLOSE" | "O" | "OPEN" | "E" | "REVERSE" | "REV" => { + if (matches!(upper.as_str(), "C" | "CLOSE") && self.closed()) + || (matches!(upper.as_str(), "O" | "OPEN") && !self.closed()) { + return Some(CmdResult::NeedPoint); + } + let mut spline = self.spline.clone()?; + let op = match upper.as_str() { "C" | "CLOSE" => "__SPLINEDIT_CLOSE__", "O" | "OPEN" => "__SPLINEDIT_OPEN__", _ => "__SPLINEDIT_REVERSE__" }; + apply_to_spline(&mut spline, op); + if self.spline.as_ref() == Some(&spline) { return Some(CmdResult::NeedPoint); } + return Some(self.replace(spline)); + } + _ => {} + }, + Step::PolylinePrecision => { + let Ok(precision) = upper.parse::() else { return Some(CmdResult::ReportError(crate::t!("Requires an integer between 0 and 99.").into_owned())); }; + if precision > 99 { return Some(CmdResult::ReportError(crate::t!("Requires an integer between 0 and 99.").into_owned())); } + return Some(self.convert_polyline(precision)); + } + Step::Refine => match upper.as_str() { + "A" | "ADD" => self.step = Step::Add, + "D" | "DELETE" => self.step = Step::Delete, + "E" | "ELEVATE" => self.step = Step::Elevate, + "M" | "MOVE" => self.step = Step::Move { index: 0, refine: true }, + "W" | "WEIGHT" => self.step = Step::Weight { index: 0 }, + "X" | "EXIT" => self.step = Step::Options, + _ => {} + }, + Step::Elevate => { + let current_order = self.spline.as_ref()?.degree.max(1) as usize + 1; + let Some(order) = upper.parse::().ok() + .filter(|order| *order >= current_order && *order <= 26) else { + return Some(CmdResult::NeedPoint); + }; + if order == current_order { + self.step = Step::Refine; + return Some(CmdResult::NeedPoint); + } + if let Some(spline) = self.refined(None, Some(order - 1)) { + self.step = Step::Refine; + return Some(self.replace(spline)); + } + } + Step::Move { index, .. } | Step::Weight { index } => { + let count = self.spline.as_ref().map_or(0, |s| s.control_points.len()); + if count == 0 { return Some(CmdResult::NeedPoint); } + let next = match upper.as_str() { "N" | "NEXT" => Some((index + 1) % count), "P" | "PREVIOUS" => Some((index + count - 1) % count), _ => None }; + if let Some(next) = next { + self.step = match self.step { Step::Move { refine, .. } => Step::Move { index: next, refine }, _ => Step::Weight { index: next } }; + } else if matches!(upper.as_str(), "S" | "SELECT") { + self.step = match self.step { + Step::Move { refine, .. } => Step::SelectVertex { weight: false, refine }, + _ => Step::SelectVertex { weight: true, refine: true }, + }; + } else if matches!(upper.as_str(), "X" | "EXIT") { + self.step = match self.step { Step::Move { refine: false, .. } => Step::Options, _ => Step::Refine }; + } else if matches!(self.step, Step::Weight { .. }) { + if let Some(weight) = upper.replace(',', ".").parse::().ok().filter(|weight| weight.is_finite() && *weight > 0.0) { + let mut spline = self.spline.clone()?; + spline.weights.resize(count, 1.0); + spline.weights[index] = weight; + spline.flags.rational = weights_are_rational(&spline.weights); + clear_fit_method(&mut spline); + return Some(self.replace(spline)); + } + } + } + _ => {} } + Some(CmdResult::NeedPoint) } - - fn on_point(&mut self, _pt: DVec3) -> CmdResult { - CmdResult::NeedPoint + fn wants_point_pick_context(&self) -> bool { + matches!(self.step, Step::Delete | Step::SelectVertex { .. }) + } + fn set_point_pick_context(&mut self, context: Option) { + self.pick_context = context; + } + fn on_point(&mut self, point: DVec3) -> CmdResult { + if !point.is_finite() { return CmdResult::NeedPoint; } + match self.step { + Step::SelectVertex { weight, refine } => { + if let Some(index) = self.picked_vertex(point) { + self.step = if weight { Step::Weight { index } } else { Step::Move { index, refine } }; + } + CmdResult::NeedPoint + } + Step::Delete => { + let Some(source) = self.spline.as_ref() else { return CmdResult::NeedPoint; }; + let Some(index) = self.picked_vertex(point) else { return CmdResult::NeedPoint; }; + let controls = source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(); + let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] } + else { source.weights.clone() }; + let curve = cadkernel::space::NurbsCurve3::new_strict(source.degree as usize, controls, + source.knots.clone(), weights).map(|curve| curve.with_periodicity(source.flags.periodic || source.flags.closed)); + let Some(curve) = curve.and_then(|curve| curve.without_control_vertex(index)) else { return CmdResult::NeedPoint; }; + let mut spline = source.clone(); + spline.degree = curve.degree() as i32; + spline.control_points = curve.control_points().iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect(); + spline.weights = curve.weights().to_vec(); + spline.knots = curve.knots().to_vec(); + clear_fit_method(&mut spline); + spline.flags.rational = curve.is_rational(); + spline.flags.planar = crate::entities::curve::spline_is_planar(&spline); + self.replace(spline) + } + Step::Add => self.refined(Some(point), None).map_or(CmdResult::NeedPoint, |spline| self.replace(spline)), + Step::Move { index, .. } => { + let Some(mut spline) = self.spline.clone() else { return CmdResult::NeedPoint; }; + let Some(vertex) = spline.control_points.get_mut(index) else { return CmdResult::NeedPoint; }; + *vertex = Vector3::new(point.x, point.y, point.z); + clear_fit_method(&mut spline); + spline.flags.planar = crate::entities::curve::spline_is_planar(&spline); + self.replace(spline) + } + _ => CmdResult::NeedPoint, + } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + Step::SelectSpline | Step::Options => CmdResult::Cancel, + Step::Join => self.finish_join(), + Step::PolylinePrecision => self.convert_polyline(10), + Step::Refine => { self.step = Step::Options; CmdResult::NeedPoint } + Step::Add | Step::Delete | Step::SelectVertex { .. } => { self.step = Step::Refine; CmdResult::NeedPoint } + Step::Elevate => { + let order = self.spline.as_ref().map_or(4, |s| s.degree as usize + 1); + self.on_text_input(&order.to_string()).unwrap_or(CmdResult::NeedPoint) + } + Step::Move { .. } | Step::Weight { .. } => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint), + } } - fn on_preview_wires(&mut self, _pt: DVec3) -> Vec { - vec![] + fn on_escape(&mut self) -> CmdResult { + if matches!(self.step, Step::Join | Step::PolylinePrecision) { + self.join_candidates.clear(); + self.step = Step::Options; + CmdResult::NeedPoint + } else { CmdResult::Cancel } } + fn on_preview_wires(&mut self, _pt: DVec3) -> Vec { vec![] } } - -// ── Spline operation helpers ─────────────────────────────────────────────── -// -// Because `CmdResult::ReplaceEntity` takes `Vec` and we need the -// host to apply it to the actual document, we use a small "deferred op" trick: -// the host applies the spline transform in `apply_cmd_result` by detecting -// that we replaced an entity with zero new entities (delete) or one new one. -// -// Here we actually build the modified entity without accessing the document — -// we only have the handle. The real transformation (close/open/reverse) is -// applied in the Scene via `apply_spline_op`. - -enum SplineOp { - Close, - Open, - Reverse, -} - -impl SplineOp { - /// Return a placeholder that encodes the op in a comment field. - /// The host `cmd_result.rs` detects SPLINEDIT replaces and calls - /// `Scene::apply_spline_op`. - fn into_marker(self, _handle: acadrust::Handle) -> EntityType { - // We can't build the modified spline here without the document. - // Return a sentinel — the actual transformation is handled by the - // apply_spline_op path in Scene. We use an XLine as a sentinel - // that the host will never actually commit (it detects the SPLINEDIT - // replace path and calls apply_spline_op instead). - // - // In practice: use ReplaceMany with empty new entities + a SplineOp - // side-channel via the xattach_path mechanism isn't clean. - // - // Simpler: encode op in the sentinel entity's layer field. - let mut sentinel = acadrust::entities::XLine::new( - acadrust::types::Vector3::zero(), - acadrust::types::Vector3::new(1.0, 0.0, 0.0), - ); - sentinel.common.layer = match self { - SplineOp::Close => "__SPLINEDIT_CLOSE__".to_string(), - SplineOp::Open => "__SPLINEDIT_OPEN__".to_string(), - SplineOp::Reverse => "__SPLINEDIT_REVERSE__".to_string(), - }; - EntityType::XLine(sentinel) - } -} - /// Apply a spline operation (CLOSE/OPEN/REVERSE) to a spline entity. /// Called from `cmd_result.rs` when the ReplaceEntity sentinel is detected. pub fn apply_spline_op(doc: &mut acadrust::CadDocument, handle: acadrust::Handle, op: &str) { let Some(EntityType::Spline(spline)) = doc.get_entity_mut(handle) else { return; }; - match op { - "__SPLINEDIT_CLOSE__" => { - if spline.control_points.len() >= 2 { - let first = spline.control_points[0]; - spline.control_points.push(first); - // Regenerate clamped knots. - spline.knots = acadrust::entities::Spline::generate_clamped_knots( - spline.degree as usize, - spline.control_points.len(), - ); - spline.fit_points.clear(); - } + apply_to_spline(spline, op); +} + +fn apply_to_spline(spline: &mut acadrust::entities::Spline, op: &str) { + let result = match op { + "__SPLINEDIT_CLOSE__" => change_closure(spline, true), + "__SPLINEDIT_OPEN__" => change_closure(spline, false), + "__SPLINEDIT_REVERSE__" => Some(super::reverse::reverse_spline(spline)), + _ => None, + }; + if let Some(result) = result { *spline = result; } +} + +fn change_closure(source: &acadrust::entities::Spline, closed: bool) -> Option { + use cadkernel::space::{NurbsCurve3, Parameterization}; + if (source.flags.closed || source.flags.periodic) == closed { return None; } + let fit_method = !source.fit_points.is_empty() || source.dwg_flags1 & 1 != 0 || source.dxf_flags & 32 != 0; + let parameterization = match source.knot_parameterization { + 1 => Parameterization::Centripetal, 2 => Parameterization::Uniform, _ => Parameterization::Chord, + }; + let xyz = |point: &Vector3| [point.x, point.y, point.z]; + let controls: Vec<_> = source.control_points.iter().map(xyz).collect(); + let degree = usize::try_from(source.degree).ok()?; + let weights = if source.weights.is_empty() { vec![1.0; controls.len()] } else { source.weights.clone() }; + if fit_method && weights.windows(2).any(|pair| pair[0] != pair[1]) { return None; } + let stored_curve = || NurbsCurve3::new_strict(degree, controls.clone(), source.knots.clone(), weights.clone()); + let mut fit_points = Vec::new(); + let curve = if fit_method { + fit_points = source.fit_points.iter().map(xyz).collect(); + if fit_points.is_empty() { + let curve = stored_curve()?; + let (start, end) = curve.domain(); + let mut parameters: Vec<_> = curve.knots().iter().copied() + .filter(|parameter| *parameter >= start && if closed { *parameter <= end } else { *parameter < end }).collect(); + parameters.dedup(); + fit_points = parameters.into_iter().map(|parameter| curve.point_at_knot(parameter)).collect(); } - "__SPLINEDIT_OPEN__" => { - if spline.control_points.len() >= 2 { - let n = spline.control_points.len(); - let first = spline.control_points[0]; - let last = spline.control_points[n - 1]; - if (first.x - last.x).abs() < 1e-9 - && (first.y - last.y).abs() < 1e-9 - && (first.z - last.z).abs() < 1e-9 - { - spline.control_points.pop(); - spline.knots = acadrust::entities::Spline::generate_clamped_knots( - spline.degree as usize, - spline.control_points.len(), - ); - spline.fit_points.clear(); - } - } - } - "__SPLINEDIT_REVERSE__" => { - spline.fit_points.reverse(); - let begin = spline.begin_tangent; - spline.begin_tangent = spline.end_tangent; - spline.end_tangent = begin; - // The knots mirror within the domain rather than being - // regenerated. Rebuilding a clamped *uniform* vector here changed - // the shape of any spline whose knots were unevenly spaced — - // which is most of them once a modeller has been near one. - match spline_to_nurbs(spline).map(|curve| curve.reversed()) { - Some(reversed) => { - let elevation = spline - .control_points - .first() - .or_else(|| spline.fit_points.first()) - .map(|p| p.z) - .unwrap_or(0.0); - spline.degree = reversed.degree() as i32; - spline.knots = reversed.knots().to_vec(); - spline.control_points = reversed - .control_points() - .iter() - .map(|p| Vector3::new(p[0], p[1], elevation)) - .collect(); - spline.weights = if reversed.is_rational() { - reversed.weights().to_vec() - } else { - Vec::new() - }; - } - // Nothing the kernel can read — a spline with too few points - // to describe a curve. Reversing its stored points is still - // the honest answer. - None => spline.control_points.reverse(), - } - } - _ => {} + let curve = if closed { + NurbsCurve3::interpolate_periodic(&fit_points, parameterization)? + } else { + NurbsCurve3::interpolate_fit(&fit_points, None, None, parameterization)? + }; + curve.compact_knots(source.control_tolerance.max(1e-9))? + } else if closed { + NurbsCurve3::from_weighted_control_polygon(degree, &controls, &weights, true)? + } else { + let curve = stored_curve()?.with_periodicity(false); + curve.without_control_vertex(controls.len().checked_sub(1)?)? + }; + let mut result = source.clone(); + result.degree = curve.degree() as i32; + result.control_points = curve.control_points().iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect(); + result.knots = curve.knots().to_vec(); + result.weights = curve.weights().to_vec(); + result.flags.closed = closed; + result.flags.periodic = closed; + if closed { result.dxf_flags |= 2048; } else { result.dxf_flags &= !2048; } + result.flags.rational = !fit_method && curve.is_rational(); + result.fit_points = if !closed && fit_method { + fit_points.iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect() + } else { Vec::new() }; + if fit_method { + result.dwg_flags1 |= 1; + result.dxf_flags |= 32 | 1024; + result.begin_tangent = Vector3::ZERO; + result.end_tangent = Vector3::ZERO; + } + result.flags.planar = crate::entities::curve::spline_is_planar(&result); + Some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::Spline; + use acadrust::Handle; + use iced::Rectangle; + + fn control_spline(points: &[[f64; 3]], degree: usize) -> Spline { + let mut spline = Spline::new(); + spline.degree = degree as i32; + spline.control_points = points + .iter() + .map(|point| Vector3::new(point[0], point[1], point[2])) + .collect(); + spline.knots = cadkernel::space::clamped_uniform_knots(degree, points.len()); + spline + } + + fn selected_command(spline: Spline) -> SplineditCommand { + let mut command = SplineditCommand::new(); + command.inject_picked_entity(EntityType::Spline(spline)); + command.on_entity_pick(Handle::new(7), DVec3::ZERO); + command + } + + fn replacement(result: CmdResult) -> Spline { + let CmdResult::ReplaceManyContinue(mut replacements) = result else { + panic!("expected a continuing replacement"); + }; + let Some(EntityType::Spline(spline)) = replacements[0].1.pop() else { + panic!("expected a spline replacement"); + }; + spline + } + + #[test] + fn moving_a_control_vertex_clears_fit_state_and_recomputes_planarity() { + let mut spline = control_spline( + &[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [2.0, 1.0, 0.0], + [3.0, 0.0, 0.0], + ], + 3, + ); + spline.fit_points = vec![Vector3::new(0.0, 0.0, 0.0)]; + spline.begin_tangent = Vector3::new(1.0, 0.0, 0.0); + spline.end_tangent = Vector3::new(1.0, 0.0, 0.0); + spline.dwg_flags1 = 1; + spline.dxf_flags = 32 | 1024; + spline.flags.planar = true; + let mut command = selected_command(spline); + command.on_text_input("MOVE"); + let moved = replacement(command.on_point(DVec3::new(0.0, 0.0, 1.0))); + assert!(moved.fit_points.is_empty()); + assert_eq!(moved.begin_tangent, Vector3::ZERO); + assert_eq!(moved.end_tangent, Vector3::ZERO); + assert_eq!(moved.dwg_flags1 & 1, 0); + assert_eq!(moved.dxf_flags & (32 | 1024), 0); + assert!(!moved.flags.planar); + } + + #[test] + fn equal_control_weights_are_not_marked_rational() { + let spline = control_spline( + &[ + [0.0, 0.0, 0.0], + [1.0, 1.0, 0.0], + [2.0, 1.0, 0.0], + [3.0, 0.0, 0.0], + ], + 3, + ); + let mut command = selected_command(spline); + command.on_text_input("REFINE"); + command.on_text_input("WEIGHT"); + let weighted = replacement(command.on_text_input("1").unwrap()); + assert!(!weighted.flags.rational); + assert_eq!(weighted.weights, vec![1.0; 4]); + } + + #[test] + fn deleting_the_only_weighted_vertex_clears_rational_and_fit_flags() { + let mut spline = control_spline( + &[ + [-0.6, 0.0, 0.0], + [-0.2, 0.4, 0.0], + [0.2, 0.4, 0.0], + [0.6, 0.0, 0.0], + ], + 2, + ); + spline.weights = vec![1.0, 2.0, 1.0, 1.0]; + spline.flags.rational = true; + spline.dwg_flags1 = 1; + spline.dxf_flags = 32 | 1024; + let mut command = selected_command(spline); + command.on_text_input("REFINE"); + command.on_text_input("DELETE"); + command.pick_context = Some(crate::command::PointPickContext { + view: glam::Mat4::IDENTITY, + eye: DVec3::ZERO, + bounds: Rectangle::new(iced::Point::ORIGIN, iced::Size::new(100.0, 100.0)), + aperture_px: 2.0, + }); + let deleted = replacement(command.on_point(DVec3::new(-0.2, 0.4, 0.0))); + assert_eq!(deleted.control_points.len(), 3); + assert!(!deleted.flags.rational); + assert_eq!(deleted.dwg_flags1 & 1, 0); + assert_eq!(deleted.dxf_flags & (32 | 1024), 0); + } + + #[test] + fn fit_only_degree_elevation_uses_the_interpolated_curve_degree() { + let mut spline = Spline::new(); + spline.degree = 1; + spline.fit_points = vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 2.0, 0.0), + Vector3::new(3.0, 0.0, 0.0), + ]; + let mut command = selected_command(spline); + command.on_text_input("REFINE"); + command.on_text_input("ELEVATE"); + assert!(matches!(command.on_text_input("invalid"), Some(CmdResult::NeedPoint))); + assert!(matches!(command.step, Step::Elevate)); + let elevated = replacement(command.on_text_input("5").unwrap()); + assert_eq!(elevated.degree, 4); + assert!(elevated.fit_points.is_empty()); + } + + #[test] + fn a_closed_weighted_control_curve_can_be_opened() { + let curve = cadkernel::space::NurbsCurve3::from_weighted_control_polygon( + 2, + &[ + [0.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [2.0, 2.0, 1.0], + [0.0, 2.0, 0.0], + ], + &[1.0, 2.0, 0.75, 1.5], + true, + ) + .unwrap(); + let mut spline = control_spline(curve.control_points(), curve.degree()); + spline.knots = curve.knots().to_vec(); + spline.weights = curve.weights().to_vec(); + spline.flags.closed = true; + spline.flags.periodic = true; + spline.flags.rational = true; + let opened = change_closure(&spline, false).expect("closed control curve opens"); + assert!(!opened.flags.closed); + assert!(!opened.flags.periodic); + assert!(cadkernel::space::NurbsCurve3::new_strict( + opened.degree as usize, + opened + .control_points + .iter() + .map(|point| [point.x, point.y, point.z]) + .collect(), + opened.knots.clone(), + opened.weights.clone(), + ) + .is_some()); + } + + #[test] + fn control_vertex_pick_uses_the_screen_aperture() { + let spline = control_spline(&[[-0.5, 0.0, 0.0], [0.5, 0.0, 0.0]], 1); + let mut command = selected_command(spline); + command.pick_context = Some(crate::command::PointPickContext { + view: glam::Mat4::IDENTITY, + eye: DVec3::ZERO, + bounds: Rectangle::new(iced::Point::ORIGIN, iced::Size::new(100.0, 100.0)), + aperture_px: 2.0, + }); + assert_eq!(command.picked_vertex(DVec3::new(-0.49, 0.0, 0.0)), Some(0)); + assert_eq!(command.picked_vertex(DVec3::new(0.0, 0.5, 0.0)), None); + } + + #[test] + fn planar_polyline_conversion_honors_source_deletion_policy() { + let handle = Handle::new(41); + let mut spline = control_spline( + &[ + [0.0, 0.0, 2.0], + [1.0, 2.0, 2.0], + [3.0, 0.0, 2.0], + ], + 2, + ); + spline.common.handle = handle; + spline.flags.planar = true; + let mut command = SplineditCommand::new().with_delete_source(false); + command.inject_picked_entity(EntityType::Spline(spline)); + command.on_entity_pick(handle, DVec3::ZERO); + let CmdResult::ReplaceMany(replacements, additions) = command.convert_polyline(10) else { + panic!("expected a polyline conversion"); + }; + assert!(replacements.is_empty()); + let [EntityType::LwPolyline(polyline)] = additions.as_slice() else { + panic!("expected a lightweight polyline"); + }; + assert!(polyline.common.handle.is_null()); + assert_eq!(polyline.elevation, 2.0); + assert!(polyline.vertices.len() > 2); + } + + #[test] + fn nonplanar_spline_converts_to_a_spatial_polyline() { + let spline = control_spline( + &[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 1.0], + [2.0, 1.0, 0.0], + [3.0, 0.0, 2.0], + ], + 3, + ); + let mut command = selected_command(spline); + let CmdResult::ReplaceMany(replacements, additions) = command.convert_polyline(10) else { + panic!("expected a polyline conversion"); + }; + assert!(additions.is_empty()); + let [(_, entities)] = replacements.as_slice() else { + panic!("expected one source replacement"); + }; + let [EntityType::Polyline3D(polyline)] = entities.as_slice() else { + panic!("expected a spatial polyline"); + }; + assert!(polyline.vertices.len() > 2); + } + + #[test] + fn closed_conversion_closes_once_and_invalid_precision_retries() { + let curve = cadkernel::space::NurbsCurve3::from_control_polygon( + 2, + &[ + [0.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [2.0, 2.0, 0.0], + [0.0, 2.0, 0.0], + ], + true, + ) + .unwrap(); + let mut spline = control_spline(curve.control_points(), curve.degree()); + spline.knots = curve.knots().to_vec(); + spline.weights = curve.weights().to_vec(); + spline.flags.closed = true; + spline.flags.periodic = true; + let mut command = selected_command(spline); + command.on_text_input("POLYLINE"); + assert!(matches!(command.on_text_input("100"), Some(CmdResult::ReportError(_)))); + assert!(matches!(command.step, Step::PolylinePrecision)); + let CmdResult::ReplaceMany(replacements, _) = command.on_enter() else { + panic!("expected the default conversion"); + }; + let [(_, entities)] = replacements.as_slice() else { + panic!("expected one source replacement"); + }; + let [EntityType::LwPolyline(polyline)] = entities.as_slice() else { + panic!("expected a lightweight polyline"); + }; + assert!(polyline.is_closed); + assert!(polyline.vertices.len() >= 4); + assert_ne!(polyline.vertices.first().unwrap().location, polyline.vertices.last().unwrap().location); } } - // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["SPLINEDIT"] }); // SplineditCommand + +fn spatial_spline(source: &acadrust::entities::Spline) -> Option { + let current = usize::try_from(source.degree).ok()?; + let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] } else { source.weights.clone() }; + let curve = if source.control_points.is_empty() && source.fit_points.len() >= 2 { + use cadkernel::space::{NurbsCurve3, Parameterization}; + let points: Vec<_> = source.fit_points.iter().map(|point| [point.x, point.y, point.z]).collect(); + let parameterization = match source.knot_parameterization { + 1 => Parameterization::Centripetal, 2 => Parameterization::Uniform, _ => Parameterization::Chord, + }; + let tangent = |point: &Vector3| (point.x != 0.0 || point.y != 0.0 || point.z != 0.0).then_some([point.x, point.y, point.z]); + if source.flags.closed || source.flags.periodic { + NurbsCurve3::interpolate_periodic(&points, parameterization)? + } else { + NurbsCurve3::interpolate_fit(&points, tangent(&source.begin_tangent), tangent(&source.end_tangent), parameterization)? + } + } else { + cadkernel::space::NurbsCurve3::new_strict(current, + source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(), + source.knots.clone(), weights)? + }; + Some(curve.with_periodicity(source.flags.closed || source.flags.periodic)) +} diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 82359b26c..beb62e80a 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -264,7 +264,13 @@ fn polyline_arc_center(entity: &EntityType, segment: usize) -> Option { } } -fn source_points(entity: &EntityType) -> Vec { +/// 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 +/// 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::Arc(arc) => vec![arc.start_point_wcs(), arc.end_point_wcs()], diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs new file mode 100644 index 000000000..56b8047ab --- /dev/null +++ b/src/scene/dwg_native_constraints.rs @@ -0,0 +1,1728 @@ +//! Maps sketch constraints to the drawing format's native associative object graph. +//! The application record remains authoritative when both representations exist. + +use acadrust::objects::{ + Assoc2dConstraintGroup, AssocAction, AssocConstraintNode, AssocConstraintNodeData, + AssocDependency, AssocEvalValue, AssocEvalVariant, AssocGeomDependency, AssocNetwork, + AssocPersistentSubentId, AssocValueDependency, AssocVariable, AssociativeData, + AssociativeObject, ObjectType, +}; +use acadrust::types::{Handle, Vector3}; +use acadrust::{CadDocument, EntityType}; +use rustc_hash::FxHashMap; + +use super::named_parameters::{DrivingValue, ParameterTable}; +use super::sketch_constraints::{ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef}; +use super::Scene; + +/// Default sub-dictionary key used for an associative network. +const NETWORK_DICTIONARY_KEY: &str = "ACAD_ASSOCNETWORK"; + +/// `AcConstraintGroupNode::GroupNodeId::kNullGroupNodeId` — node id 0 is +/// reserved as "no node", so real node ids start at 1. +const FIRST_NODE_ID: i32 = 1; + +/// Native implicit-point type values. +mod implicit_point_type { + pub const START: u8 = 0; + pub const END: u8 = 1; + #[allow(dead_code)] // reserved for SketchRef marker -2, not wired up yet + pub const MID: u8 = 2; + pub const CENTER: u8 = 3; +} + +/// One entity's constraint-group presence: its geometry node's id, plus +/// whichever `ImplicitPoint` nodes have been created for it so far, keyed +/// by our own `SketchRef::marker` convention so a second constraint +/// referencing the same point reuses the same node instead of duplicating it. +#[derive(Default)] +struct EntityNodes { + geometry_node_id: i32, + points: FxHashMap, +} + +/// Builds one scope's `Assoc2dConstraintGroup` graph incrementally, +/// allocating node ids and deduplicating geometry/point nodes per entity. +struct GroupBuilder<'a> { + document: &'a CadDocument, + nodes: Vec, + next_node_id: i32, + entities: FxHashMap, + /// Real entity handles that ended up with a geometry node — what + /// `AssocGeomDependency` objects get created for, in first-touch order. + referenced_entities: Vec, +} + +impl<'a> GroupBuilder<'a> { + fn new(document: &'a CadDocument) -> Self { + Self { + document, + nodes: Vec::new(), + next_node_id: FIRST_NODE_ID, + entities: FxHashMap::default(), + referenced_entities: Vec::new(), + } + } + + fn alloc_node_id(&mut self) -> i32 { + let id = self.next_node_id; + self.next_node_id += 1; + id + } + + fn push_node(&mut self, node_id: i32, class_name: &'static str, data: AssocConstraintNodeData) { + self.nodes.push(AssocConstraintNode { + node_id, + status: 1, + connections: Vec::new(), + class_name: class_name.to_string(), + registry_flag: true, + data, + }); + } + + /// The geometry node id for `entity`'s whole shape, creating a `Line` + /// (well: `BoundedLine`, §4 — nothing we draw is an infinite + /// construction line), `Circle`, or `Arc` node the first time this + /// entity is referenced. Returns `None` for an entity kind this pass + /// doesn't support as constraint geometry — 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). + fn geometry_node(&mut self, handle: Handle) -> Option { + if let Some(existing) = self.entities.get(&handle) { + if existing.geometry_node_id != 0 { + return Some(existing.geometry_node_id); + } + } + let entity = self.document.get_entity(handle)?; + let node_id = self.alloc_node_id(); + match entity { + EntityType::Line(l) => { + let point = l.start; + let direction = (l.end - l.start).normalize(); + self.push_node( + node_id, + "AcDbAssocGeomDependency", // placeholder, overwritten below + AssocConstraintNodeData::None, + ); + let last = self.nodes.last_mut().expect("just pushed"); + last.class_name = "ACCONSTRAINEDBOUNDEDLINE".to_string(); + last.data = AssocConstraintNodeData::BoundedLine { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point, + direction, + is_ray: false, + start_point: l.start, + end_point: l.end, + }; + } + EntityType::Circle(c) => { + let (axis_x, _axis_y) = + crate::scene::view::transform::ocs_axes((c.normal.x, c.normal.y, c.normal.z)); + self.push_node( + node_id, + "ACCONSTRAINEDCIRCLE", + AssocConstraintNodeData::Circle { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + center: c.center, + normal: c.normal, + direction: Vector3::new(axis_x.0, axis_x.1, axis_x.2), + radius: c.radius, + start_parameter: 0.0, + end_parameter: std::f64::consts::TAU, + reserved: 0.0, + }, + ); + } + EntityType::Arc(a) => { + let (axis_x, _axis_y) = + crate::scene::view::transform::ocs_axes((a.normal.x, a.normal.y, a.normal.z)); + let direction = Vector3::new(axis_x.0, axis_x.1, axis_x.2); + let start_point = a.center + + Vector3::new(axis_x.0, axis_x.1, axis_x.2) * (a.radius * a.start_angle.cos()) + + Vector3::new(-axis_x.1, axis_x.0, 0.0) * (a.radius * a.start_angle.sin()); + let end_point = a.center + + Vector3::new(axis_x.0, axis_x.1, axis_x.2) * (a.radius * a.end_angle.cos()) + + Vector3::new(-axis_x.1, axis_x.0, 0.0) * (a.radius * a.end_angle.sin()); + self.push_node( + node_id, + "ACCONSTRAINEDARC", + AssocConstraintNodeData::Arc { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + center: a.center, + normal: a.normal, + direction, + radius: a.radius, + start_parameter: a.start_angle, + end_parameter: a.end_angle, + reserved: 0.0, + start_point, + end_point, + }, + ); + } + _ => return None, + } + self.entities.entry(handle).or_default().geometry_node_id = node_id; + if !self.referenced_entities.contains(&handle) { + self.referenced_entities.push(handle); + } + Some(node_id) + } + + /// Returns the point-node id for a line endpoint or curve center, + /// creating it the first time the marker is referenced. + fn point_node(&mut self, handle: Handle, marker: i32) -> Option { + if let Some(existing) = self + .entities + .get(&handle) + .and_then(|e| e.points.get(&marker)) + { + return Some(*existing); + } + let curve_id = self.geometry_node(handle)?; + let point_type = match marker { + 0 => implicit_point_type::START, + 1 => implicit_point_type::END, + -3 => implicit_point_type::CENTER, + _ => return None, + }; + let node_id = self.alloc_node_id(); + self.push_node( + node_id, + "ACCONSTRAINEDIMPLICITPOINT", + AssocConstraintNodeData::ImplicitPoint { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: None, + point_type, + point_index: -1, + curve_id, + }, + ); + self.entities + .entry(handle) + .or_default() + .points + .insert(marker, node_id); + Some(node_id) + } + + /// The node id `SketchRef` resolves to: a point node for a marked + /// reference, the whole geometry node otherwise. + fn ref_node(&mut self, r: SketchRef) -> Option { + match r.marker { + Some(marker) => self.point_node(r.entity, marker), + None => self.geometry_node(r.entity), + } + } + + /// Records an undirected edge on both nodes. + fn connect(&mut self, a: i32, b: i32) { + if let Some(node) = self.nodes.iter_mut().find(|n| n.node_id == a) { + node.connections.push(b); + } + if let Some(node) = self.nodes.iter_mut().find(|n| n.node_id == b) { + node.connections.push(a); + } + } +} + +/// Class name for a plain `Geometrical`-shaped constraint. +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"), + // the native format's own `GeomConstraintType` enum (native SDK's + // `AcGeomConstraint.h`) lists `kNormal` alongside `kPerpendicular` + // as a distinct, real geometric-constraint kind. + ConstraintKind::Normal => Some("ACNORMALCONSTRAINT"), + ConstraintKind::Concentric => Some("ACCONCENTRICCONSTRAINT"), + ConstraintKind::CenterPoint => Some("ACCENTERPOINTCONSTRAINT"), + ConstraintKind::Colinear => Some("ACCOLINEARCONSTRAINT"), + ConstraintKind::Fixed => Some("ACFIXEDCONSTRAINT"), + ConstraintKind::Midpoint => Some("ACMIDPOINTCONSTRAINT"), + ConstraintKind::PointOnCurve => Some("ACPOINTCURVECONSTRAINT"), + ConstraintKind::Symmetric => Some("ACSYMMETRICCONSTRAINT"), + // Also a plain `Geometrical`-shaped node in the native format's own class list + // (`is_plain_geometrical_constraint`) — unlike `Equal`, its class + // name never branches on entity type, so it belongs here rather + // than in the caller's per-entity-type dispatch. + ConstraintKind::EqualDistance => Some("ACEQUALDISTANCECONSTRAINT"), + // `Equal` needs the resolved entity types, so the caller handles it. + ConstraintKind::Equal + | ConstraintKind::Parallel + | ConstraintKind::Distance + | ConstraintKind::Angle + | ConstraintKind::Radius + | ConstraintKind::Diameter + | ConstraintKind::DistanceX + | ConstraintKind::DistanceY => None, + // No native DWG representation exists at all: neither + // `AcExplicitConstr.h` (Distance/Angle/RadiusDiameter — no + // ArcLength-shaped class) nor `acadrust`'s `AssocConstraintNodeData` + // has anything for it. `constraint_node` below falls through to + // its own `_ => None` for this kind, same "can't persist to DWG, + // XRecord-only" gap the dependency-chain DXF omission already + // documents. + ConstraintKind::ArcLength => None, + } +} + +/// `Diameter`/`DistanceX`/`DistanceY` reuse `Radius`'/`Distance`'s own DWG +/// class — native implementations represents them as the same +/// `AcRadiusDiameterConstraint`/`AcDistanceConstraint` object with a +/// different `RadiusDiameterConstrType`/`DirectionType` mode byte (set in +/// `constraint_node` below), not a distinct native class. +const fn dimensional_class_name(kind: ConstraintKind) -> &'static str { + match kind { + ConstraintKind::Distance | ConstraintKind::DistanceX | ConstraintKind::DistanceY => { + "ACDISTANCECONSTRAINT" + } + ConstraintKind::Angle => "ACANGLECONSTRAINT", + ConstraintKind::Radius | ConstraintKind::Diameter => "ACRADIUSDIAMETERCONSTRAINT", + _ => "", + } +} + +/// Builds one `AssocConstraintNode` for `constraint`, returning its node id +/// so the caller can track which ones still need a `value_dependency` +/// patched in once handles can be allocated (pass 2, `Allocator` — building +/// node *shape* only needs read access to resolve `Equal`'s line-vs-circle +/// split, `AssocGeomDependency`/`AssocValueDependency` handles need mutable +/// access to the same document, so this has to be two passes). `None` if +/// this constraint's refs don't resolve to nodes this pass supports +/// (mirrors `sketch_solve::build_constraint`'s own "skip, don't error" +/// contract for an unbuildable constraint). +fn constraint_node( + builder: &mut GroupBuilder, + document: &CadDocument, + constraint: &SketchConstraint, + needs_value_dependency: &mut Vec<(i32, Option)>, +) -> Option { + let refs: &[SketchRef] = &constraint.refs; + if let Some(class_name) = geometrical_class_name(constraint.kind) { + let target_refs: Vec = refs.iter().filter_map(|r| builder.ref_node(*r)).collect(); + if target_refs.len() != refs.len() { + return None; + } + let owner_id = *target_refs.first()?; + let node_id = builder.alloc_node_id(); + builder.push_node( + node_id, + class_name, + AssocConstraintNodeData::Geometrical { + owner_id, + is_implied: false, + is_active: true, + }, + ); + for target in &target_refs { + builder.connect(node_id, *target); + } + return Some(node_id); + } + match constraint.kind { + ConstraintKind::Parallel => { + let [a, b] = refs else { return None }; + let (na, nb) = (builder.ref_node(*a)?, builder.ref_node(*b)?); + let node_id = builder.alloc_node_id(); + builder.push_node( + node_id, + "ACPARALLELCONSTRAINT", + AssocConstraintNodeData::Parallel { + owner_id: na, + is_implied: false, + is_active: true, + datum_line_index: None, + }, + ); + builder.connect(node_id, na); + builder.connect(node_id, nb); + Some(node_id) + } + ConstraintKind::Equal => { + let [a, b] = refs else { return None }; + let (na, nb) = (builder.ref_node(*a)?, builder.ref_node(*b)?); + let both_lines = matches!(document.get_entity(a.entity), Some(EntityType::Line(_))) + && matches!(document.get_entity(b.entity), Some(EntityType::Line(_))); + let class_name = if both_lines { + "ACEQUALLENGTHCONSTRAINT" + } else { + "ACEQUALRADIUSCONSTRAINT" + }; + let node_id = builder.alloc_node_id(); + builder.push_node( + node_id, + class_name, + AssocConstraintNodeData::Geometrical { + owner_id: na, + is_implied: false, + is_active: true, + }, + ); + builder.connect(node_id, na); + builder.connect(node_id, nb); + Some(node_id) + } + ConstraintKind::Distance + | ConstraintKind::DistanceX + | ConstraintKind::DistanceY + | ConstraintKind::Angle + | ConstraintKind::Radius + | ConstraintKind::Diameter => { + let target_refs: Vec = match constraint.kind { + ConstraintKind::Radius | ConstraintKind::Diameter => { + vec![builder.ref_node(*refs.first()?)?] + } + _ => { + let [a, b] = refs else { return None }; + vec![builder.ref_node(*a)?, builder.ref_node(*b)?] + } + }; + let owner_id = *target_refs.first()?; + let node_id = builder.alloc_node_id(); + let data = match constraint.kind { + // Plain two-point distance: undirected (`kNotDirected`). + ConstraintKind::Distance => AssocConstraintNodeData::Distance { + owner_id, + is_implied: false, + is_active: true, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + direction_type: 0, + distance: None, + }, + // X-only/Y-only: `kFixedDirection` with the fixed unit + // vector the distance is measured along — the native format's own + // representation of DistanceX/DistanceY, not a distinct + // constraint class. + ConstraintKind::DistanceX => AssocConstraintNodeData::Distance { + owner_id, + is_implied: false, + is_active: true, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + direction_type: 1, + distance: Some(Vector3::new(1.0, 0.0, 0.0)), + }, + ConstraintKind::DistanceY => AssocConstraintNodeData::Distance { + owner_id, + is_implied: false, + is_active: true, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + direction_type: 1, + distance: Some(Vector3::new(0.0, 1.0, 0.0)), + }, + ConstraintKind::Angle => AssocConstraintNodeData::Angle { + owner_id, + is_implied: false, + is_active: true, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + sector_type: 0, + }, + ConstraintKind::Radius => AssocConstraintNodeData::RadiusDiameter { + owner_id, + is_implied: false, + is_active: true, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + mode: 0, + }, + // `kCircleDiameter` — same class as Radius (`mode: 0` / + // `kCircleRadius`), different mode byte. + ConstraintKind::Diameter => AssocConstraintNodeData::RadiusDiameter { + owner_id, + is_implied: false, + is_active: true, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + mode: 1, + }, + _ => unreachable!(), + }; + builder.push_node(node_id, dimensional_class_name(constraint.kind), data); + for target in &target_refs { + builder.connect(node_id, *target); + } + needs_value_dependency.push((node_id, constraint.driving_param.clone())); + Some(node_id) + } + _ => None, + } +} + +/// Everything a scope's native graph needs beyond the group's own object: +/// the `AssocGeomDependency` and `AssocValueDependency`/`AssocVariable` +/// objects it references by handle, and the network/dictionary wiring. +struct Allocator<'a> { + document: &'a mut CadDocument, + /// One `AssocVariable` handle per distinct named parameter actually + /// referenced in this scope, created once and shared by every + /// constraint that references the same name (mirrors how the native format's own + /// variables work — one object, many dependents). + variables: FxHashMap, +} + +impl Allocator<'_> { + fn insert_associative( + &mut self, + owner: Handle, + dxf_name: &str, + cpp_class_name: &str, + data: AssociativeData, + ) -> Handle { + let handle = self.document.allocate_handle(); + self.insert_associative_at(handle, owner, dxf_name, cpp_class_name, data); + handle + } + + /// Like [`Allocator::insert_associative`], but for a handle already + /// allocated up front — needed for the group/network pair, which + /// reference each other (`AssocAction::owning_network` / + /// `AssocNetwork::owned_actions`) and so must both have real handles + /// before either object is actually built. + fn insert_associative_at( + &mut self, + handle: Handle, + owner: Handle, + dxf_name: &str, + cpp_class_name: &str, + data: AssociativeData, + ) { + let object = AssociativeObject { + handle, + owner, + dxf_name: dxf_name.to_string(), + cpp_class_name: cpp_class_name.to_string(), + data, + ..Default::default() + }; + self.document + .objects + .insert(handle, ObjectType::Associative(object)); + } + + fn geom_dependency(&mut self, group_handle: Handle, entity: Handle) -> Handle { + self.insert_associative( + group_handle, + "ACDBASSOCGEOMDEPENDENCY", + "AcDbAssocGeomDependency", + AssociativeData::GeomDependency(AssocGeomDependency { + dependency: AssocDependency { + class_version: 1, + is_read_dependency: true, + is_write_dependency: true, + is_attached_to_object: true, + dependent_on: entity, + ..Default::default() + }, + class_version: 1, + enabled: true, + // Point and curve addressing lives on constraint-node data. + persistent_subent: AssocPersistentSubentId::default(), + }), + ) + } + + /// Returns the `AssocVariable` handle for `name`, creating it once per scope. + fn variable(&mut self, group_handle: Handle, name: &str, formula: &str) -> Handle { + if let Some(&handle) = self.variables.get(name) { + return handle; + } + let handle = self.insert_associative( + group_handle, + "ACDBASSOCVARIABLE", + "AcDbAssocVariable", + AssociativeData::Variable(AssocVariable { + action: AssocAction::default(), + class_version: 1, + name: name.to_string(), + expression: formula.to_string(), + // Empty selects the format's default expression evaluator. + evaluator: String::new(), + description: String::new(), + value: AssocEvalVariant::default(), + has_cached_value: false, + cached_value: String::new(), + flag: false, + reserved: 0, + }), + ); + self.variables.insert(name.to_string(), handle); + handle + } + + /// Uses a variable dependency for named values and a null dependency for + /// literal values. + fn value_dependency( + &mut self, + group_handle: Handle, + variable_handle: Option, + resolved: f64, + ) -> Handle { + let value = AssocEvalVariant { + code: 40, + value: AssocEvalValue::Real(resolved), + }; + self.insert_associative( + group_handle, + "ACDBASSOCVALUEDEPENDENCY", + "AcDbAssocValueDependency", + AssociativeData::ValueDependency(AssocValueDependency { + dependency: AssocDependency { + class_version: 1, + is_read_dependency: true, + is_write_dependency: false, + dependent_on: variable_handle.unwrap_or(Handle::NULL), + ..Default::default() + }, + class_version: 1, + name: String::new(), + value, + }), + ) + } +} + +/// Sets the dependency on whole-geometry nodes. Implicit points retain a +/// null dependency and refer to their owning geometry node by `curve_id`. +fn set_geometry_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { + match data { + AssocConstraintNodeData::BoundedLine { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Circle { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Arc { + geometry_dependency, + .. + } => { + *geometry_dependency = handle; + } + _ => {} + } +} + +/// Patches a dimensional-constraint node's `value_dependency` field +/// (`Distance`/`Angle`/`RadiusDiameter` — the three kinds +/// [`constraint_node`] defers to `needs_value_dependency`). +fn set_value_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { + match data { + AssocConstraintNodeData::Distance { + value_dependency, .. + } + | AssocConstraintNodeData::Angle { + value_dependency, .. + } + | AssocConstraintNodeData::RadiusDiameter { + value_dependency, .. + } => { + *value_dependency = handle; + } + _ => {} + } +} + +/// The DWG/DXF CLASSES-table entries this module's five `AssociativeObject` +/// kinds need before they can be written with a real class number. +/// Confirmed by reading the actual writer: `write_associative_object` +/// resolves the class number via +/// `document.classes.get_by_name(&object.dxf_name)` +/// (`io/dwg/dwg_stream_writers/object_writer/associative.rs:700-709`), and +/// falls back to a generic type code 500 for every unregistered class alike +/// if it misses — which also means the CLASSES section written to the file +/// never declares them, so a reload can't dispatch the type-500 objects back +/// to `read_associative_object` either (`dwg_document_builder.rs`'s object +/// dispatcher keys off exactly that class-number -> name mapping). A fresh +/// `CadDocument::initialize_defaults` only registers classes every drawing +/// needs, not ones specific to this feature, so this module registers its +/// own — the same pattern `CadDocument::register_object_context_class` +/// already uses for a different object family. +const ASSOC_CLASSES: &[(&str, &str)] = &[ + ("ASSOC2DCONSTRAINTGROUP", "AcDbAssoc2dConstraintGroup"), + ("ASSOCNETWORK", "AcDbAssocNetwork"), + ("ASSOCVARIABLE", "AcDbAssocVariable"), + ("ASSOCGEOMDEPENDENCY", "AcDbAssocGeomDependency"), + ("ASSOCVALUEDEPENDENCY", "AcDbAssocValueDependency"), +]; + +fn ensure_associative_classes_registered(document: &mut CadDocument) { + for (dxf_name, cpp_class_name) in ASSOC_CLASSES { + if document.classes.get_by_name(dxf_name).is_some() { + continue; + } + document.classes.add_or_update(acadrust::classes::DxfClass { + dxf_name: dxf_name.to_string(), + cpp_class_name: cpp_class_name.to_string(), + application_name: "ObjectDBX Classes".to_string(), + proxy_flags: acadrust::classes::ProxyFlags( + acadrust::classes::ProxyFlags::ERASE_ALLOWED.0 + | acadrust::classes::ProxyFlags::CLONING_ALLOWED.0 + | acadrust::classes::ProxyFlags::DISABLES_PROXY_WARNING_DIALOG.0, + ), + instance_count: 0, + was_zombie: false, + is_an_entity: false, + class_number: 0, + // 499 = "object" (non-entity) — every class here is one. + item_class_id: 0x1F3, + dwg_version: 0, + maintenance_version: 0, + unknown1: 0, + unknown2: 0, + }); + } +} + +/// Ensures `owner`'s extension dictionary exists, returning its handle. +/// `acadrust::CadDocument` only exposes creating one through +/// [`CadDocument::ensure_xrecord`] — the dictionary side map is private to +/// that crate. A throwaway XRecord is therefore created +/// and immediately removed purely to force that wiring through the one +/// public entry point that gets it right for both DWG and DXF. +fn ensure_extension_dictionary(document: &mut CadDocument, owner: Handle) -> Handle { + if let Some(handle) = document.extension_dictionary_handle(owner) { + return handle; + } + const BOOTSTRAP_KEY: &str = "OCS_DWG_NATIVE_BOOTSTRAP"; + document.ensure_xrecord(owner, BOOTSTRAP_KEY); + let dictionary_handle = document + .extension_dictionary_handle(owner) + .expect("ensure_xrecord just created the extension dictionary"); + remove_dictionary_entry(document, dictionary_handle, BOOTSTRAP_KEY); + dictionary_handle +} + +/// Removes a named entry from `dictionary_handle`, and recursively removes +/// the object it pointed to — used both for the bootstrap XRecord above and +/// for clearing a stale `ACAD_ASSOCNETWORK` entry before a resave rebuilds +/// it from scratch. +fn remove_dictionary_entry(document: &mut CadDocument, dictionary_handle: Handle, key: &str) { + let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary_handle) + else { + return; + }; + let Some(index) = dictionary + .entries + .iter() + .position(|(name, _)| name.eq_ignore_ascii_case(key)) + else { + return; + }; + let (_, target) = dictionary.entries.remove(index); + remove_owned_recursive(document, target); +} + +fn set_dictionary_entry( + document: &mut CadDocument, + dictionary_handle: Handle, + key: &str, + target: Handle, +) { + let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary_handle) + else { + return; + }; + if let Some((_, handle)) = dictionary + .entries + .iter_mut() + .find(|(name, _)| name.eq_ignore_ascii_case(key)) + { + *handle = target; + } else { + dictionary.add_entry(key, target); + } +} + +/// Removes `root` and every object transitively owned by it so rebuilding the +/// graph cannot accumulate orphaned dependency or variable objects. +fn remove_owned_recursive(document: &mut CadDocument, root: Handle) { + if root.is_null() { + return; + } + let children: Vec = document + .objects + .keys() + .copied() + .filter(|&h| document.object_owner(h) == Some(root)) + .collect(); + for child in children { + remove_owned_recursive(document, child); + } + document.objects.remove(&root); +} + +/// Whether `owner`'s scope already carries a materialized native graph — +/// from an earlier save with the setting on, or from a file that came from +/// real compatible applications. Drives the "sync-if-present, create-only-if- +/// enabled" rule in [`Scene::materialize_dwg_native_constraints_for_save`]: +/// a scope that already has this graph keeps getting it rebuilt on every +/// save regardless of the current setting, so turning the setting off never +/// leaves a stale, out-of-sync graph behind — only a scope that doesn't have +/// one yet is gated by the setting. +fn has_native_network(document: &CadDocument, owner: Handle) -> bool { + let Some(dictionary_handle) = document.extension_dictionary_handle(owner) else { + return false; + }; + let Some(ObjectType::Dictionary(dictionary)) = document.objects.get(&dictionary_handle) else { + return false; + }; + dictionary.get(NETWORK_DICTIONARY_KEY).is_some() +} + +/// One scope's worth of +/// [`Scene::materialize_dwg_native_constraints_for_save`] — see that +/// method's doc comment for the save-time contract this implements. +fn materialize_scope( + document: &mut CadDocument, + owner: Handle, + set: &SketchConstraintSet, + parameters: &ParameterTable, +) { + let dictionary_handle = ensure_extension_dictionary(document, owner); + remove_dictionary_entry(document, dictionary_handle, NETWORK_DICTIONARY_KEY); + + // Pass 1 (read-only): constraint-node shapes. + let mut needs_value_dependency = Vec::new(); + let (mut nodes, entities) = { + let mut builder = GroupBuilder::new(document); + for constraint in &set.constraints { + if !constraint.enabled { + continue; + } + constraint_node( + &mut builder, + document, + constraint, + &mut needs_value_dependency, + ); + } + let GroupBuilder { + nodes, entities, .. + } = builder; + (nodes, entities) + }; + if nodes.is_empty() { + return; + } + + // Root pseudo-node (`nodes[0]`): confirmed mandatory by reading the DWG + // writer — `Assoc2dConstraintGroup`'s write path only emits node data at + // all when `nodes.first()` is `Some`, so an empty `Vec` would skip node + // serialization entirely. This synthetic anchor is what makes every + // other node reach the file, not decoration. + let all_ids: Vec = nodes.iter().map(|n| n.node_id).collect(); + nodes.insert( + 0, + AssocConstraintNode { + node_id: 0, + status: 1, + connections: all_ids, + class_name: String::new(), + registry_flag: false, + data: AssocConstraintNodeData::None, + }, + ); + + // Pass 2 (mutable): allocate handles for everything the node shapes + // above still reference as `Handle::NULL`. The group and network + // reference each other, so both handles are reserved up front. + let group_handle = document.allocate_handle(); + let network_handle = document.allocate_handle(); + let mut allocator = Allocator { + document, + variables: FxHashMap::default(), + }; + + for (entity_handle, entity_nodes) in &entities { + if entity_nodes.geometry_node_id == 0 { + continue; + } + let dep_handle = allocator.geom_dependency(group_handle, *entity_handle); + if let Some(node) = nodes + .iter_mut() + .find(|n| n.node_id == entity_nodes.geometry_node_id) + { + set_geometry_dependency(&mut node.data, dep_handle); + } + } + + for (node_id, driving) in needs_value_dependency { + let Some(driving) = driving else { continue }; + let Ok(resolved) = driving.resolve(parameters) else { + continue; + }; + let variable_handle = match &driving { + DrivingValue::Named(name) => { + let formula = parameters + .get(name) + .map(|p| p.source.clone()) + .unwrap_or_default(); + Some(allocator.variable(group_handle, name, &formula)) + } + DrivingValue::Literal(_) => None, + }; + let dep_handle = allocator.value_dependency(group_handle, variable_handle, resolved); + if let Some(node) = nodes.iter_mut().find(|n| n.node_id == node_id) { + set_value_dependency(&mut node.data, dep_handle); + } + } + + let group = Assoc2dConstraintGroup { + action: AssocAction { + owning_network: network_handle, + ..Default::default() + }, + version: 1, + flag: true, + // Only world-XY entities reach native graph materialization. + work_plane: [Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y], + dependency: Handle::NULL, + actions: Vec::new(), + nodes, + }; + allocator.insert_associative_at( + group_handle, + network_handle, + "ASSOC2DCONSTRAINTGROUP", + "AcDbAssoc2dConstraintGroup", + AssociativeData::ConstraintGroup(group), + ); + + let network = AssocNetwork { + action: AssocAction::default(), + network_version: 1, + network_action_index: 0, + actions: Vec::new(), + owned_actions: vec![group_handle], + }; + allocator.insert_associative_at( + network_handle, + dictionary_handle, + "ASSOCNETWORK", + "AcDbAssocNetwork", + AssociativeData::Network(network), + ); + + set_dictionary_entry( + allocator.document, + dictionary_handle, + NETWORK_DICTIONARY_KEY, + network_handle, + ); +} + +impl Scene { + /// Rebuilds every sketch scope's native `AssocNetwork`/ + /// `Assoc2dConstraintGroup` graph from `self.sketch_constraints`, + /// replacing whatever this module wrote on a previous save. + /// + /// `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 classes_registered = false; + for index in 0..self.sketch_constraints.len() { + let set = self.sketch_constraints[index].clone(); + let owner = set.scope.owner_handle(&self.document); + if owner.is_null() { + continue; + } + if !enabled && !has_native_network(&self.document, owner) { + continue; + } + if !classes_registered { + ensure_associative_classes_registered(&mut self.document); + classes_registered = true; + } + materialize_scope(&mut self.document, owner, &set, &self.named_parameters); + } + } +} + +#[cfg(test)] +mod tests { + use super::super::sketch_constraints::SketchScope; + use super::*; + use acadrust::entities::{Arc, Circle, Line}; + + fn line_entity(scene: &mut Scene, start: (f64, f64), end: (f64, f64)) -> Handle { + scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(start.0, start.1, 0.0), + Vector3::new(end.0, end.1, 0.0), + ))) + } + + fn circle_entity(scene: &mut Scene, center: (f64, f64), radius: f64) -> Handle { + scene.add_entity(EntityType::Circle(Circle::from_center_radius( + Vector3::new(center.0, center.1, 0.0), + radius, + ))) + } + + fn arc_entity(scene: &mut Scene, center: (f64, f64), radius: f64) -> Handle { + scene.add_entity(EntityType::Arc(Arc::from_center_radius_angles( + Vector3::new(center.0, center.1, 0.0), + radius, + 0.0, + std::f64::consts::PI, + ))) + } + + /// Digs a scope's materialized native graph back out of `document`: + /// `owner`'s extension dictionary -> `ACAD_ASSOCNETWORK` entry -> + /// its one owned action, which (for this module) is always the + /// `Assoc2dConstraintGroup` itself. Panics with a descriptive message on + /// any broken link, since every test using this expects the graph to be + /// present and well-formed. + fn native_group(document: &CadDocument, owner: Handle) -> &Assoc2dConstraintGroup { + let dict = document + .extension_dictionary_handle(owner) + .expect("extension dictionary should exist"); + let Some(ObjectType::Dictionary(dictionary)) = document.objects.get(&dict) else { + panic!("expected owner's extension dictionary object to exist"); + }; + let network_handle = dictionary + .get(NETWORK_DICTIONARY_KEY) + .expect("ACAD_ASSOCNETWORK entry should exist"); + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::Network(network), + .. + })) = document.objects.get(&network_handle) + else { + panic!("expected an AssocNetwork object at the ACAD_ASSOCNETWORK handle"); + }; + let group_handle = *network + .owned_actions + .first() + .expect("network should own the constraint group"); + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), + .. + })) = document.objects.get(&group_handle) + else { + panic!( + "expected an Assoc2dConstraintGroup object at the network's owned action handle" + ); + }; + group + } + + #[test] + fn horizontal_and_vertical_constraints_round_trip_through_dwg_and_dxf() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let b = line_entity(&mut scene, (10.0, 0.0), (10.0, 5.0)); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Vertical, vec![SketchRef::whole(b)], None); + + scene.materialize_dwg_native_constraints_for_save(true); + let owner = scene.document.header.model_space_block_handle; + // Sanity before the round trip even happens: root + 2 geometry + // nodes + 2 constraint nodes. + let before = native_group(&scene.document, owner); + assert_eq!( + before.nodes.len(), + 5, + "expected root + 2 geometry + 2 constraint nodes, got {:#?}", + before.nodes + ); + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let reloaded = crate::io::load_bytes(&format!("dwg_native_poc.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded, owner); + let class_names: Vec<&str> = + group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + assert!( + class_names.contains(&"ACHORIZONTALCONSTRAINT"), + "{ext}: missing horizontal constraint node, got {class_names:?}" + ); + assert!( + class_names.contains(&"ACVERTICALCONSTRAINT"), + "{ext}: missing vertical constraint node, got {class_names:?}" + ); + assert_eq!( + class_names + .iter() + .filter(|&&n| n == "ACCONSTRAINEDBOUNDEDLINE") + .count(), + 2, + "{ext}: expected both lines' geometry nodes to survive, got {class_names:?}" + ); + } + } + + fn distance_with_named_parameter_scene() -> (Scene, Handle) { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let b = line_entity(&mut scene, (20.0, 0.0), (30.0, 0.0)); + scene + .named_parameters + .set("gap", "5") + .expect("defining the parameter should succeed"); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], + Some(DrivingValue::Named("gap".to_string())), + ); + let owner = scene.document.header.model_space_block_handle; + (scene, owner) + } + + fn has_variable_named(document: &CadDocument, name: &str) -> bool { + document.objects.values().any(|obj| { + matches!( + obj, + ObjectType::Associative(AssociativeObject { data: AssociativeData::Variable(v), .. }) + if v.name == name + ) + }) + } + + /// DWG is the format this module's dependency chain (`AssocGeomDependency` + /// / `AssocValueDependency` / `AssocVariable`) actually survives a save + /// through — see [`a_dxf_save_keeps_only_the_constraint_group_shell`] for + /// the DXF side of this same scene and why it's different, not a bug here. + #[test] + fn a_dwg_save_keeps_the_full_dependency_chain_including_the_named_variable() { + let (mut scene, owner) = distance_with_named_parameter_scene(); + scene.materialize_dwg_native_constraints_for_save(true); + assert!( + has_variable_named(&scene.document, "gap"), + "expected an AssocVariable BEFORE serialization" + ); + + let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) + .unwrap_or_else(|e| panic!("save to dwg: {e}")); + let reloaded = crate::io::load_bytes("dwg_native_named_param.dwg", bytes) + .unwrap_or_else(|e| panic!("reload dwg: {e}")); + + let group = native_group(&reloaded, owner); + assert!( + group + .nodes + .iter() + .any(|n| n.class_name == "ACDISTANCECONSTRAINT"), + "expected an ACDISTANCECONSTRAINT node, got {:?}", + group + .nodes + .iter() + .map(|n| &n.class_name) + .collect::>() + ); + assert!( + has_variable_named(&reloaded, "gap"), + "expected the AssocVariable \"gap\" to survive a DWG round trip" + ); + } + + /// Confirmed by reading the actual DXF writer + /// (`io/dxf/writer/section_writer.rs`'s `is_unrestorable_assoc_object`, + /// called from its `ObjectType::Associative` write arm): `acadrust`'s DXF + /// path deliberately does not write `AssocDependency`/ + /// `AssocValueDependency`/`AssocGeomDependency`/`AssocVariable` objects at + /// all ("Unrestorable associative-framework objects are not written") — + /// DWG has no equivalent filter. So a DXF save keeps the + /// `Assoc2dConstraintGroup`/`AssocNetwork` shell (and every constraint + /// node's own embedded geometry — real coordinates, not lost), but each + /// node's `geometry_dependency`/`value_dependency` handle points at + /// nothing once reloaded, and no `AssocVariable` survives at all. This is + /// a real, upstream `acadrust` limitation, not something this module can + /// work around without patching that dependency — out of scope here (see + /// this project's stated preference, `named_parameters_persist.rs`, to + /// pick a different approach rather than patch `acadrust`). DWG is + /// therefore the only format this feature round-trips fully through. + #[test] + fn a_dxf_save_keeps_only_the_constraint_group_shell() { + let (mut scene, owner) = distance_with_named_parameter_scene(); + scene.materialize_dwg_native_constraints_for_save(true); + + let bytes = crate::io::save_to_bytes(&scene.document, "dxf", scene.document.version) + .unwrap_or_else(|e| panic!("save to dxf: {e}")); + let reloaded = crate::io::load_bytes("dwg_native_named_param.dxf", bytes) + .unwrap_or_else(|e| panic!("reload dxf: {e}")); + + let group = native_group(&reloaded, owner); + assert!( + group + .nodes + .iter() + .any(|n| n.class_name == "ACDISTANCECONSTRAINT"), + "expected the ACDISTANCECONSTRAINT node's shell to survive, got {:?}", + group + .nodes + .iter() + .map(|n| &n.class_name) + .collect::>() + ); + assert!( + !has_variable_named(&reloaded, "gap"), + "acadrust's DXF writer should not carry AssocVariable; update the native materializer if this changes" + ); + } + + #[test] + fn resaving_replaces_rather_than_accumulates_native_objects() { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + + scene.materialize_dwg_native_constraints_for_save(true); + let first_count = scene.document.objects.len(); + scene.materialize_dwg_native_constraints_for_save(true); + let second_count = scene.document.objects.len(); + assert_eq!( + first_count, second_count, + "a resave with the same constraints must not accumulate new objects" + ); + } + + #[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 owner = scene.document.header.model_space_block_handle; + let dict = scene.document.extension_dictionary_handle(owner); + if let Some(dict) = dict { + if let Some(ObjectType::Dictionary(dictionary)) = scene.document.objects.get(&dict) { + assert!( + dictionary.get(NETWORK_DICTIONARY_KEY).is_none(), + "an unconstrained scope should not materialize a native network" + ); + } + } + } + + fn model_space_has_native_network(scene: &Scene) -> bool { + has_native_network( + &scene.document, + scene.document.header.model_space_block_handle, + ) + } + + #[test] + fn the_setting_off_skips_a_scope_with_no_existing_native_graph() { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + + scene.materialize_dwg_native_constraints_for_save(false); + assert!( + !model_space_has_native_network(&scene), + "a scope with no prior native graph should stay untouched while the setting is off" + ); + } + + /// The "sync-if-present, create-only-if-enabled" rule from + /// `Scene::materialize_dwg_native_constraints_for_save`'s doc comment: + /// once a scope has the native graph (here, from an earlier save with + /// the setting on), turning the setting off must not freeze that graph + /// out of date — it should keep tracking the current constraints. + #[test] + fn the_setting_off_still_resyncs_a_scope_that_already_has_a_native_graph() { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + scene.materialize_dwg_native_constraints_for_save(true); + assert!( + model_space_has_native_network(&scene), + "sanity: the graph should exist after the first, enabled save" + ); + + let b = line_entity(&mut scene, (0.0, 0.0), (0.0, 10.0)); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Vertical, vec![SketchRef::whole(b)], None); + scene.materialize_dwg_native_constraints_for_save(false); + + let owner = scene.document.header.model_space_block_handle; + let group = native_group(&scene.document, owner); + assert!( + group.nodes.iter().any(|n| n.class_name == "ACVERTICALCONSTRAINT"), + "the second, disabled-setting save should still pick up the new Vertical constraint, got {:?}", + group.nodes.iter().map(|n| &n.class_name).collect::>() + ); + } + + /// The other half of the same rule: disabling the setting after a scope + /// already has the graph must not make that graph disappear either — + /// only remove it by clearing the scope's constraints (existing + /// `an_empty_constraint_set_leaves_no_native_network` behavior), not by + /// toggling the setting off. + #[test] + fn the_setting_off_does_not_remove_an_existing_native_graph() { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + scene.materialize_dwg_native_constraints_for_save(true); + scene.materialize_dwg_native_constraints_for_save(false); + assert!( + model_space_has_native_network(&scene), + "an existing native graph must survive a disabled-setting resave, not be silently dropped" + ); + } + + /// These `ConstraintKind`s all reuse the same generic + /// `Geometrical`-shaped node path `geometrical_class_name` drives (see + /// its own doc comment) — one representative mix of ref shapes (2 whole + /// entities, an asymmetric point+whole pair, and a 3-ref case) is + /// enough to confirm that path handles them all, without one dedicated + /// test per kind. + #[test] + fn new_constraint_kinds_round_trip_with_their_own_dwg_native_class_names() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let circle_a = circle_entity(&mut scene, (0.0, 0.0), 3.0); + let circle_b = circle_entity(&mut scene, (5.0, 5.0), 1.0); + let line_a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let line_b = line_entity(&mut scene, (3.0, 4.0), (7.0, 6.0)); + let marker = line_entity(&mut scene, (20.0, 20.0), (21.0, 21.0)); + let axis = line_entity(&mut scene, (0.0, 0.0), (0.0, 10.0)); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add( + ConstraintKind::Concentric, + vec![SketchRef::center(circle_a), SketchRef::center(circle_b)], + None, + ); + set.add( + ConstraintKind::Colinear, + vec![SketchRef::whole(line_a), SketchRef::whole(line_b)], + None, + ); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(line_a)], None); + set.add( + ConstraintKind::CenterPoint, + vec![SketchRef::point(marker, 0), SketchRef::center(circle_a)], + None, + ); + set.add( + ConstraintKind::Midpoint, + vec![SketchRef::point(marker, 1), SketchRef::whole(line_a)], + None, + ); + set.add( + ConstraintKind::PointOnCurve, + vec![SketchRef::point(marker, 0), SketchRef::whole(circle_a)], + None, + ); + set.add( + ConstraintKind::EqualDistance, + vec![ + SketchRef::point(line_b, 0), + SketchRef::point(line_b, 1), + SketchRef::point(line_a, 0), + SketchRef::point(line_a, 1), + ], + None, + ); + set.add( + ConstraintKind::Symmetric, + vec![ + SketchRef::center(circle_a), + SketchRef::center(circle_b), + SketchRef::whole(axis), + ], + None, + ); + set.add( + ConstraintKind::Normal, + vec![SketchRef::whole(circle_a), SketchRef::whole(line_a)], + None, + ); + + scene.materialize_dwg_native_constraints_for_save(true); + let owner = scene.document.header.model_space_block_handle; + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let reloaded = crate::io::load_bytes(&format!("new_kinds.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded, owner); + let class_names: Vec<&str> = + group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + + for expected in [ + "ACCONCENTRICCONSTRAINT", + "ACCOLINEARCONSTRAINT", + "ACFIXEDCONSTRAINT", + "ACCENTERPOINTCONSTRAINT", + "ACMIDPOINTCONSTRAINT", + "ACPOINTCURVECONSTRAINT", + "ACEQUALDISTANCECONSTRAINT", + "ACSYMMETRICCONSTRAINT", + "ACNORMALCONSTRAINT", + ] { + assert!( + class_names.contains(&expected), + "{ext}: missing {expected} node, got {class_names:?}" + ); + } + } + } + + /// Constraint-parity Phase 1: an Arc registers in the solver as its own + /// center/radius (`sketch_solve.rs`'s `EntityGeom::Circle` reuse), and + /// this module already had `ACCONSTRAINEDARC` geometry-dependency + /// support in place before that. This confirms the two sides actually + /// meet: a Concentric/Tangent/Radius constraint referencing an arc + /// still gets its expected class names AND its geometry node is the + /// arc-specific one, through a real DWG/DXF byte round trip — not just + /// the in-memory graph this test module builds directly. + #[test] + fn constraints_referencing_an_arc_round_trip_with_the_arc_geometry_node() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0); + let circle = circle_entity(&mut scene, (10.0, -6.0), 2.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add( + ConstraintKind::Concentric, + vec![SketchRef::center(arc), SketchRef::center(circle)], + None, + ); + set.add( + ConstraintKind::Radius, + vec![SketchRef::whole(arc)], + Some(crate::scene::named_parameters::DrivingValue::Literal(9.0)), + ); + + scene.materialize_dwg_native_constraints_for_save(true); + let owner = scene.document.header.model_space_block_handle; + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let reloaded = crate::io::load_bytes(&format!("arc_ref.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded, owner); + let class_names: Vec<&str> = + group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + assert!( + class_names.contains(&"ACCONCENTRICCONSTRAINT"), + "{ext}: missing ACCONCENTRICCONSTRAINT, got {class_names:?}" + ); + assert!( + class_names.contains(&"ACRADIUSDIAMETERCONSTRAINT"), + "{ext}: missing ACRADIUSDIAMETERCONSTRAINT, got {class_names:?}" + ); + assert!( + group + .nodes + .iter() + .any(|n| n.class_name == "ACCONSTRAINEDARC"), + "{ext}: the arc should have its own geometry node, got {class_names:?}" + ); + } + } + + /// Constraint-parity Phase 4b: a Coincident constraint referencing an + /// arc's actual *endpoint* (marker 0, not its center via `-3` or its + /// whole curve) round-trips correctly too — `SketchRef`'s marker is + /// serialized generically regardless of what entity type or point it + /// addresses, so this needs no dedicated DWG-side wiring beyond what + /// already existed; this test is here to actually confirm that rather + /// than assume it. + #[test] + fn a_coincident_constraint_on_an_arcs_endpoint_round_trips() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0); + let line = line_entity(&mut scene, (20.0, 20.0), (21.0, 20.0)); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Coincident, + vec![SketchRef::point(arc, 0), SketchRef::point(line, 0)], + None, + ); + + scene.materialize_dwg_native_constraints_for_save(true); + let owner = scene.document.header.model_space_block_handle; + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let reloaded = crate::io::load_bytes(&format!("arc_endpoint_ref.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded, owner); + let class_names: Vec<&str> = + group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + assert!( + class_names.contains(&"ACPOINTCOINCIDENCECONSTRAINT"), + "{ext}: missing ACPOINTCOINCIDENCECONSTRAINT, got {class_names:?}" + ); + assert!( + group + .nodes + .iter() + .any(|n| n.class_name == "ACCONSTRAINEDARC"), + "{ext}: the arc should still have its own geometry node, got {class_names:?}" + ); + } + } + + /// Constraint-parity Phase 5: an ellipse isn't a supported geometry + /// node type in `geometry_node` yet (see its own doc comment for why), + /// so a Concentric constraint referencing one degrades to "not in the + /// native graph" — the same "skip, don't error" contract every other + /// unbuildable native-constraint case gets — while the other + /// constraint in the same scope (Fixed, on a plain circle) still + /// persists normally, and the ellipse constraint itself still survives + /// in this app's own XRecord format. + #[test] + fn a_constraint_referencing_an_ellipse_is_absent_from_the_native_graph_but_not_from_xrecord() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let ellipse = scene.add_entity(EntityType::Ellipse( + acadrust::entities::Ellipse::from_center_axes( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(4.0, 0.0, 0.0), + 0.5, + ), + )); + let circle = circle_entity(&mut scene, (10.0, 10.0), 2.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add( + ConstraintKind::Concentric, + vec![SketchRef::center(ellipse), SketchRef::center(circle)], + None, + ); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(circle)], None); + + scene.materialize_sketch_constraints_for_save(); + scene.materialize_dwg_native_constraints_for_save(true); + let owner = scene.document.header.model_space_block_handle; + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let reloaded_doc = crate::io::load_bytes(&format!("ellipse_ref.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded_doc, owner); + let class_names: Vec<&str> = + group.nodes.iter().map(|n| n.class_name.as_str()).collect(); + assert!( + class_names.contains(&"ACFIXEDCONSTRAINT"), + "{ext}: the other constraint should still persist, got {class_names:?}" + ); + assert!( + !class_names.contains(&"ACCONCENTRICCONSTRAINT"), + "{ext}: the ellipse-referencing Concentric constraint has no supported geometry node and must not appear, got {class_names:?}" + ); + + let mut reloaded_scene = Scene::new(); + reloaded_scene.document = reloaded_doc; + reloaded_scene.load_sketch_constraints_from_document(); + let restored = reloaded_scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap_or_else(|| { + panic!("{ext}: no ModelSpace constraint set survived the round trip") + }); + let kinds: Vec = restored.constraints.iter().map(|c| c.kind).collect(); + assert!( + kinds.contains(&ConstraintKind::Concentric), + "{ext}: the ellipse constraint should still round-trip via XRecord, got {kinds:?}" + ); + } + } + + /// Constraint-parity Phase 4a: `ArcLength` has no 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 ACRADIUSDIAMETERCONSTRAINT, got {class_names:?}" + ); + assert!( + !class_names.iter().any(|n| n.contains("ARCLENGTH")), + "{ext}: ArcLength has no native DWG class and must not appear here, got {class_names:?}" + ); + + // Still present in this app's own (XRecord-backed) constraint + // set — reload into a fresh `Scene` and read it back the same + // way `sketch_persist.rs`'s own round-trip tests do. + let mut reloaded_scene = Scene::new(); + reloaded_scene.document = reloaded_doc; + reloaded_scene.load_sketch_constraints_from_document(); + let restored = reloaded_scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap_or_else(|| { + panic!("{ext}: no ModelSpace constraint set survived the round trip") + }); + let kinds: Vec = restored.constraints.iter().map(|c| c.kind).collect(); + assert!( + kinds.contains(&ConstraintKind::ArcLength), + "{ext}: ArcLength should still round-trip via XRecord, got {kinds:?}" + ); + } + } + + /// Constraint-parity Phase 2: `Diameter`/`DistanceX`/`DistanceY` reuse + /// `Radius`'/`Distance`'s own DWG class with a different + /// `RadiusDiameterConstrType`/`DirectionType` mode byte, matching real + /// the native format's representation (this module's own research, folded into + /// `dimensional_class_name`'s doc comment). Confirms that byte actually + /// survives a real DWG/DXF write+read, not just the in-memory node this + /// module built. + #[test] + fn diameter_and_directed_distance_constraints_round_trip_with_their_mode_byte() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let circle = circle_entity(&mut scene, (0.0, 0.0), 3.0); + let line = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add( + ConstraintKind::Diameter, + vec![SketchRef::whole(circle)], + Some(DrivingValue::Literal(16.0)), + ); + set.add( + ConstraintKind::DistanceX, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Literal(10.0)), + ); + set.add( + ConstraintKind::DistanceY, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Literal(3.0)), + ); + + scene.materialize_dwg_native_constraints_for_save(true); + let owner = scene.document.header.model_space_block_handle; + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let reloaded = crate::io::load_bytes(&format!("diameter_directed.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); + + let group = native_group(&reloaded, owner); + let radius_diameter_nodes: Vec<_> = group + .nodes + .iter() + .filter_map(|n| match &n.data { + AssocConstraintNodeData::RadiusDiameter { mode, .. } => Some(*mode), + _ => None, + }) + .collect(); + assert_eq!(radius_diameter_nodes, vec![1], "{ext}: Diameter should round-trip as mode=1 (kCircleDiameter), got {radius_diameter_nodes:?}"); + + let distance_nodes: Vec<_> = group + .nodes + .iter() + .filter_map(|n| match &n.data { + AssocConstraintNodeData::Distance { + direction_type, + distance, + .. + } => Some((*direction_type, *distance)), + _ => None, + }) + .collect(); + assert_eq!( + distance_nodes.len(), + 2, + "{ext}: expected DistanceX and DistanceY nodes, got {distance_nodes:?}" + ); + assert!( + distance_nodes.contains(&(1, Some(Vector3::new(1.0, 0.0, 0.0)))), + "{ext}: DistanceX should round-trip as direction_type=1 with a (1,0,0) direction, got {distance_nodes:?}" + ); + assert!( + distance_nodes.contains(&(1, Some(Vector3::new(0.0, 1.0, 0.0)))), + "{ext}: DistanceY should round-trip as direction_type=1 with a (0,1,0) direction, got {distance_nodes:?}" + ); + } + } +} diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 02ed14956..497e6df2b 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -2691,6 +2691,12 @@ impl Scene { self.solid_models = HashMap::default(); *self.camera.borrow_mut() = Camera::default(); self.camera_generation += 1; + // A brand-new/replaced document has none of these yet — without + // 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.named_parameters = crate::scene::named_parameters::ParameterTable::new(); self.bump_geometry(); } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 83771658a..db0198c49 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -31,11 +31,14 @@ mod camera_ops; pub(crate) mod centerline; pub(crate) mod dimension_assoc; pub(crate) mod centermark; +mod dwg_native_constraints; mod entity; mod group_layer; mod layout; mod limits; mod modify; +pub mod named_parameters; +mod named_parameters_persist; mod mspace; mod page_setup; mod paper; @@ -43,6 +46,9 @@ mod preview; mod project; mod scene_markers; mod selection; +pub mod sketch_constraints; +mod sketch_persist; +mod sketch_solve; pub(crate) use boundary::{ boundary_entities, boundary_entities_from_sources, boundary_faces, @@ -327,6 +333,16 @@ 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 + /// changed (e.g. ERASE removing an entity's constraints along with it via + /// `refresh_sketch_constraints`'s deletion policy). `sketch_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, + named_parameters_before: Option, poisoned: bool, } @@ -336,18 +352,26 @@ impl UndoRecording { self.poisoned } - /// No entity or object entry was recorded (nothing to undo). + /// No entity, object, constraint, or parameter state was recorded. pub fn is_empty(&self) -> bool { - self.order.is_empty() && self.object_order.is_empty() + self.order.is_empty() + && self.object_order.is_empty() + && self.sketch_constraints_order.is_empty() + && self.named_parameters_before.is_none() } - /// Consume both recording directories in deterministic first-touch order. - /// A `None` image means the entity/object was added by the command. + /// 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 + /// 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)>, + Option, ) { let entities = self .order @@ -359,7 +383,12 @@ impl UndoRecording { .drain(..) .map(|h| (h, self.object_before.remove(&h).flatten())) .collect(); - (entities, objects) + let sketch_constraints = self + .sketch_constraints_order + .drain(..) + .filter_map(|scope| self.sketch_constraints_before.remove(&scope).map(|before| (scope, before))) + .collect(); + (entities, objects, sketch_constraints, self.named_parameters_before) } /// Entity-only convenience used by the focused Scene delta tests. @@ -1839,13 +1868,6 @@ pub struct Scene { pub block_meshes: HashMap, /// Kernel B-reps used by solid operations and exact-geometry saves. pub solid_models: HashMap, - /// Memoized DocApi volume/centroid per (handle, geometry_epoch) so repeated - /// mass queries on the same solid are O(1). Persists across DocApi dispatches. - #[cfg(not(target_arch = "wasm32"))] - pub doc_api_mass_cache: std::collections::HashMap, - /// Bounds uncached mass computations in one API request. - #[cfg(not(target_arch = "wasm32"))] - pub doc_api_cold_tess_used: usize, /// GPU render data for raster images (RasterImage entities), keyed by handle. pub images: HashMap, /// The viewport that is currently "entered" (MSPACE mode). @@ -1901,6 +1923,12 @@ 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, + /// Document-wide named-parameter and expression table. + /// Persisted through a single drawing XRecord. + pub(crate) named_parameters: named_parameters::ParameterTable, /// Tessellated block definitions in block-local coords, keyed by render /// background and block epoch. Model and Paper adapt black/white colours /// differently; retaining both variants prevents a full block rebuild on @@ -2198,10 +2226,6 @@ impl Scene { material_base_dir: None, block_meshes: HashMap::default(), solid_models: HashMap::default(), - #[cfg(not(target_arch = "wasm32"))] - doc_api_mass_cache: std::collections::HashMap::default(), - #[cfg(not(target_arch = "wasm32"))] - doc_api_cold_tess_used: 0, images: HashMap::default(), active_viewport: None, bg_color: [33.0 / 255.0, 40.0 / 255.0, 48.0 / 255.0, 1.0], @@ -2218,6 +2242,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(), + named_parameters: named_parameters::ParameterTable::new(), has_associative_centers: std::cell::Cell::new(None), block_defn_cache: RefCell::new(HashMap::default()), entity_index_cache: RefCell::new(None), @@ -2660,6 +2686,35 @@ impl Scene { } } + /// Record one sketch-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` + /// 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( + &mut self, + scope: sketch_constraints::SketchScope, + before: sketch_constraints::SketchConstraintSet, + ) { + 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); + } + } + } + + /// Records the drawing-wide parameter table before its first mutation in + /// the current undo transaction. + pub(crate) fn record_undo_named_parameters_before(&mut self) { + let before = self.named_parameters.clone(); + if let Some(recording) = self.undo_recording.as_mut() { + recording.named_parameters_before.get_or_insert(before); + } + } + /// Flag the open recording as touching non-entity state (a new layer, a /// group edit, a `*D` block record) that a pure-entity delta cannot /// restore. The app's per-command predicate keeps this from firing. @@ -2781,6 +2836,13 @@ impl Scene { changes.push(change); } } + if !self.sketch_constraints.is_empty() { + for change in self.refresh_sketch_constraints(&changes) { + if !changes.iter().any(|(handle, _)| *handle == change.0) { + changes.push(change); + } + } + } if !changes.is_empty() { self.refresh_dependency_index_for_changes(&changes); } @@ -12046,7 +12108,7 @@ mod delta_undo_tests { let handle = scene.add_entity(EntityType::RasterImage(image)); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned()); - let (entities, objects) = rec.into_recorded_images(); + let (entities, objects, _sketch_constraints, _named_parameters) = rec.into_recorded_images(); assert_eq!(entities.len(), 1); assert_eq!(entities[0].0, handle); assert_eq!(objects.len(), 1); @@ -12070,7 +12132,7 @@ mod delta_undo_tests { scene.erase_entities(&[h1]); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned()); - let (entities, objects) = rec.into_recorded_images(); + let (entities, objects, _sketch_constraints, _named_parameters) = rec.into_recorded_images(); assert_eq!(entities.len(), 1); assert_eq!(entities[0].0, h1); assert_eq!(objects.len(), 1); diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index f24423b91..5e7367141 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -1,6 +1,6 @@ // Shared value types used by the dispatch and grip systems. -use acadrust::types::{Color as AcadColor, LineWeight}; +use acadrust::types::{Color as AcadColor, Handle, LineWeight}; use glam::DVec3; /// The kind of value held by a property row. @@ -58,6 +58,28 @@ pub enum PropValue { /// an editable text_input. Unlike the other rows the routing key is the /// tag carried here, not the row's `&'static str` field. AttrText { tag: String, value: String }, + /// A clickable link to one or more entities — the Constraints section's + /// row for one persistent sketch constraint. Clicking it selects every + /// entity in `handles` in the viewport. `conflicting` mirrors + /// `SketchConstraintSet::conflicts`, tinting the row the same danger + /// color the constraint's glyph pill already uses. + EntityLink { handles: Vec, conflicting: bool }, + /// One row of the document-wide named-parameter table (Parameters + /// section, shown when nothing is selected). `index` is the row's + /// position in `ParameterTable::iter()` order — stable across edits to + /// other rows, used to route `PropParamInput`/`PropParamCommit`/ + /// `PropParamDelete` back to the right parameter without re-keying on a + /// name that might itself be mid-edit. `resolved` is the live value or + /// error, recomputed fresh every render (same "cheap enough to rebuild" + /// approach the old modal's preview column already used). + ParamRow { + index: usize, + name: String, + formula: String, + resolved: Result, + }, + /// The trailing "+ Add parameter" row in the Parameters section. + ParamAddRow, } /// A single property row in the Properties panel. diff --git a/src/scene/modify.rs b/src/scene/modify.rs index a3c9434bd..0de4ad2e7 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -752,6 +752,8 @@ impl Scene { .map(|&h| (h, ChangeKind::Added)) .collect(); self.bump_entities(&changes); + // Constraints wholly within the duplicated selection follow it. + self.duplicate_sketch_constraints_for(&handle_map); self.refresh_meshes_for_handles(&refresh_solid_handles); new_handles } diff --git a/src/scene/named_parameters.rs b/src/scene/named_parameters.rs new file mode 100644 index 000000000..3682af689 --- /dev/null +++ b/src/scene/named_parameters.rs @@ -0,0 +1,958 @@ +//! Named, formula-capable parameters stored per drawing. +//! Formulas are parsed locally so dependency cycles can be rejected before evaluation. + +use super::Scene; +use serde::{Deserialize, Serialize}; +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)] +pub enum Expr { + Number(f64), + Ref(String), + Neg(Box), + Add(Box, Box), + Sub(Box, Box), + Mul(Box, Box), + Div(Box, Box), + Pow(Box, Box), + Call(String, Vec), +} + +impl Expr { + /// Every parameter name this expression reads, deduplicated in + /// first-seen order. This is the entire basis for cycle detection + /// (`ParameterTable::set`): one dependency edge per name returned here. + fn refs(&self, out: &mut Vec) { + match self { + Expr::Number(_) => {} + Expr::Ref(name) => { + if !out.contains(name) { + out.push(name.clone()); + } + } + Expr::Neg(a) => a.refs(out), + Expr::Add(a, b) + | Expr::Sub(a, b) + | Expr::Mul(a, b) + | Expr::Div(a, b) + | Expr::Pow(a, b) => { + a.refs(out); + b.refs(out); + } + Expr::Call(_, args) => { + for arg in args { + arg.refs(out); + } + } + } + } +} + +/// Everything that can go wrong parsing, defining, or resolving a +/// parameter. Every case is reported back to the caller (and, in later +/// stages, the UI) rather than panicking — matching this codebase's +/// established pattern of isolating a bad derived value instead of letting +/// it take down an unrelated computation (e.g. dangling-constraint handling +/// in `sketch_solve.rs`). +#[derive(Debug, Clone, PartialEq)] +pub enum ParamError { + /// A formula's text doesn't parse. The `String` is a short, human- + /// readable reason, not a machine-parseable code. + Parse(String), + /// Not a valid identifier, or collides with a builtin function name + /// (`is_reserved_name`). + InvalidName(String), + /// Accepting this formula would close a circular reference right now. + /// The path reads start-to-finish, e.g. `["a", "b", "a"]` for `a = b`, + /// `b = a`. + Cycle(Vec), + UnknownFunction(String), + WrongArgCount { + function: String, + expected: usize, + found: usize, + }, + /// Raised by `resolve`/`resolve_all`, not `set` — a formula may + /// legitimately reference a parameter that doesn't exist *yet* (order of + /// definition shouldn't matter, mirroring a spreadsheet); this only + /// surfaces once something actually tries to read the missing value. + UndefinedReference(String), + DivisionByZero, +} + +impl fmt::Display for ParamError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ParamError::Parse(msg) => write!(f, "formula error: {msg}"), + ParamError::InvalidName(name) => write!(f, "'{name}' is not a valid parameter name"), + ParamError::Cycle(path) => write!(f, "circular reference: {}", path.join(" -> ")), + ParamError::UnknownFunction(name) => write!(f, "unknown function '{name}'"), + ParamError::WrongArgCount { + function, + expected, + found, + } => { + write!( + f, + "{function}() expects {expected} argument(s), found {found}" + ) + } + ParamError::UndefinedReference(name) => write!(f, "'{name}' is not defined"), + ParamError::DivisionByZero => write!(f, "division by zero"), + } + } +} + +impl std::error::Error for ParamError {} + +/// The builtin function set. It also supplies the reserved-word list that +/// `is_reserved_name` checks a parameter name against, so a formula +/// like `sqrt(x)` is never ambiguous between "call the builtin" and +/// "multiply by a parameter named sqrt". +const BUILTIN_FUNCTIONS: &[&str] = &["sqrt", "abs", "pow", "min", "max"]; + +pub fn is_reserved_name(name: &str) -> bool { + BUILTIN_FUNCTIONS.contains(&name) +} + +/// A valid parameter name: starts with a letter or underscore, followed by +/// letters, digits, or underscores. +pub fn is_valid_name(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_alphanumeric() || c == '_') +} + +/// A dimensional constraint's literal or named driving value. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum DrivingValue { + Literal(f64), + Named(String), +} + +impl DrivingValue { + /// Resolves to a number: a literal resolves to itself (never fails); a + /// named reference resolves through `table`, which can fail the same + /// ways any [`ParameterTable::resolve`] call can (undefined reference, + /// division by zero — a cycle should be unreachable here since `set` + /// already refuses to create one). + pub fn resolve(&self, table: &ParameterTable) -> Result { + match self { + DrivingValue::Literal(value) => Ok(*value), + DrivingValue::Named(name) => table.resolve(name), + } + } +} + +impl From for DrivingValue { + fn from(value: f64) -> Self { + DrivingValue::Literal(value) + } +} + +fn call_builtin(name: &str, args: &[f64]) -> Result { + fn check_arity(name: &str, args: &[f64], expected: usize) -> Result<(), ParamError> { + if args.len() != expected { + return Err(ParamError::WrongArgCount { + function: name.to_string(), + expected, + found: args.len(), + }); + } + Ok(()) + } + match name { + "sqrt" => { + check_arity(name, args, 1)?; + Ok(args[0].sqrt()) + } + "abs" => { + check_arity(name, args, 1)?; + Ok(args[0].abs()) + } + "pow" => { + check_arity(name, args, 2)?; + Ok(args[0].powf(args[1])) + } + "min" => { + check_arity(name, args, 2)?; + Ok(args[0].min(args[1])) + } + "max" => { + check_arity(name, args, 2)?; + Ok(args[0].max(args[1])) + } + _ => Err(ParamError::UnknownFunction(name.to_string())), + } +} + +#[derive(Debug, Clone, PartialEq)] +enum Token { + Number(f64), + Ident(String), + Plus, + Minus, + Star, + Slash, + Caret, + LParen, + RParen, + Comma, +} + +fn tokenize(src: &str) -> Result, ParamError> { + let chars: Vec = src.chars().collect(); + let mut tokens = Vec::new(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c.is_whitespace() { + i += 1; + continue; + } + match c { + '+' => { + tokens.push(Token::Plus); + i += 1; + } + '-' => { + tokens.push(Token::Minus); + i += 1; + } + '*' => { + tokens.push(Token::Star); + i += 1; + } + '/' => { + tokens.push(Token::Slash); + i += 1; + } + '^' => { + tokens.push(Token::Caret); + i += 1; + } + '(' => { + tokens.push(Token::LParen); + i += 1; + } + ')' => { + tokens.push(Token::RParen); + i += 1; + } + ',' => { + tokens.push(Token::Comma); + i += 1; + } + _ if c.is_ascii_digit() || c == '.' => { + let start = i; + let mut seen_dot = c == '.'; + i += 1; + while i < chars.len() { + let d = chars[i]; + if d.is_ascii_digit() { + i += 1; + } else if d == '.' && !seen_dot { + seen_dot = true; + i += 1; + } else { + break; + } + } + // Scientific notation: 1e-3, 2.5E10. Only consumed when a + // digit (optionally signed) actually follows the e/E, so a + // bare trailing "e" is left for the identifier lexer instead + // (not reachable today since no builtin/constant is named + // "e", but keeps this branch from eating an identifier that + // happens to start with it, e.g. a formula like `2 * ecc`). + if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { + let mut j = i + 1; + if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { + j += 1; + } + if j < chars.len() && chars[j].is_ascii_digit() { + while j < chars.len() && chars[j].is_ascii_digit() { + j += 1; + } + i = j; + } + } + let text: String = chars[start..i].iter().collect(); + let value: f64 = text + .parse() + .map_err(|_| ParamError::Parse(format!("invalid number '{text}'")))?; + tokens.push(Token::Number(value)); + } + _ if c.is_alphabetic() || c == '_' => { + let start = i; + i += 1; + while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') { + i += 1; + } + tokens.push(Token::Ident(chars[start..i].iter().collect())); + } + other => return Err(ParamError::Parse(format!("unexpected character '{other}'"))), + } + } + Ok(tokens) +} + +struct Parser<'a> { + tokens: &'a [Token], + pos: usize, +} + +impl<'a> Parser<'a> { + fn peek(&self) -> Option<&Token> { + self.tokens.get(self.pos) + } + + fn bump(&mut self) -> Option { + let t = self.tokens.get(self.pos).cloned(); + if t.is_some() { + self.pos += 1; + } + t + } + + fn expect(&mut self, expected: Token) -> Result<(), ParamError> { + match self.bump() { + Some(t) if t == expected => Ok(()), + Some(t) => Err(ParamError::Parse(format!( + "expected {expected:?}, found {t:?}" + ))), + None => Err(ParamError::Parse(format!( + "expected {expected:?}, found end of formula" + ))), + } + } + + // Precedence, low to high: expr (+ -) > term (* /) > unary (-) > power (^) > primary. + // `^` binds tighter than unary minus on its left operand but is + // right-associative on its own exponent (`parse_unary` recursing back + // into `parse_power` covers `2^-2` == `2^(-2)`, and `2^3^2` == + // `2^(3^2)`). + fn parse_expr(&mut self) -> Result { + let mut lhs = self.parse_term()?; + loop { + match self.peek() { + Some(Token::Plus) => { + self.pos += 1; + lhs = Expr::Add(Box::new(lhs), Box::new(self.parse_term()?)); + } + Some(Token::Minus) => { + self.pos += 1; + lhs = Expr::Sub(Box::new(lhs), Box::new(self.parse_term()?)); + } + _ => break, + } + } + Ok(lhs) + } + + fn parse_term(&mut self) -> Result { + let mut lhs = self.parse_unary()?; + loop { + match self.peek() { + Some(Token::Star) => { + self.pos += 1; + lhs = Expr::Mul(Box::new(lhs), Box::new(self.parse_unary()?)); + } + Some(Token::Slash) => { + self.pos += 1; + lhs = Expr::Div(Box::new(lhs), Box::new(self.parse_unary()?)); + } + _ => break, + } + } + Ok(lhs) + } + + fn parse_unary(&mut self) -> Result { + match self.peek() { + Some(Token::Minus) => { + self.pos += 1; + Ok(Expr::Neg(Box::new(self.parse_unary()?))) + } + Some(Token::Plus) => { + self.pos += 1; + self.parse_unary() + } + _ => self.parse_power(), + } + } + + fn parse_power(&mut self) -> Result { + let base = self.parse_primary()?; + if let Some(Token::Caret) = self.peek() { + self.pos += 1; + let exponent = self.parse_unary()?; + return Ok(Expr::Pow(Box::new(base), Box::new(exponent))); + } + Ok(base) + } + + fn parse_primary(&mut self) -> Result { + match self.bump() { + Some(Token::Number(n)) => Ok(Expr::Number(n)), + Some(Token::Ident(name)) => { + if matches!(self.peek(), Some(Token::LParen)) { + self.pos += 1; + let mut args = Vec::new(); + if !matches!(self.peek(), Some(Token::RParen)) { + loop { + args.push(self.parse_expr()?); + if matches!(self.peek(), Some(Token::Comma)) { + self.pos += 1; + } else { + break; + } + } + } + self.expect(Token::RParen)?; + Ok(Expr::Call(name, args)) + } else { + Ok(Expr::Ref(name)) + } + } + Some(Token::LParen) => { + let inner = self.parse_expr()?; + self.expect(Token::RParen)?; + Ok(inner) + } + Some(other) => Err(ParamError::Parse(format!("unexpected token {other:?}"))), + None => Err(ParamError::Parse("unexpected end of formula".to_string())), + } + } +} + +/// Parses a formula's text into an [`Expr`], without evaluating or +/// resolving any reference. `ParameterTable::set` is the usual entry point; +/// exposed standalone so a UI stage can validate-as-you-type without +/// touching the table. +pub fn parse(source: &str) -> Result { + let tokens = tokenize(source)?; + if tokens.is_empty() { + return Err(ParamError::Parse("empty formula".to_string())); + } + let mut parser = Parser { + tokens: &tokens, + pos: 0, + }; + let expr = parser.parse_expr()?; + if parser.pos != tokens.len() { + return Err(ParamError::Parse(format!( + "unexpected trailing token {:?}", + tokens[parser.pos] + ))); + } + Ok(expr) +} + +/// One named parameter: a name plus its formula, both the original typed +/// text (`source`, for redisplay/re-editing) and the parsed [`Expr`] +/// (`expr`, for evaluation). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Parameter { + pub name: String, + pub source: String, + pub expr: Expr, +} + +/// Every named parameter for one document. +/// +/// `parameters` is intentionally not `pub` (unlike the sibling +/// `SketchConstraintSet::constraints`): this table's entire value is that +/// every stored formula is already known to be parse-valid and cycle-free, +/// an invariant only `set`/`remove` maintain. A direct external push could +/// silently violate it. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ParameterTable { + parameters: Vec, +} + +impl ParameterTable { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.parameters.is_empty() + } + + pub fn len(&self) -> usize { + self.parameters.len() + } + + /// Insertion order — the order parameters were first defined in, stable + /// across edits to existing ones. The natural listing order for a + /// future parameters panel. + pub fn iter(&self) -> impl Iterator { + self.parameters.iter() + } + + pub fn get(&self, name: &str) -> Option<&Parameter> { + self.parameters.iter().find(|p| p.name == name) + } + + pub fn contains(&self, name: &str) -> bool { + self.get(name).is_some() + } + + fn index_of(&self, name: &str) -> Option { + self.parameters.iter().position(|p| p.name == name) + } + + /// Defines a new parameter or redefines an existing one's formula (one + /// upsert operation — there is no separate "must not already exist" + /// entry point; a future UI stage that wants add-vs-rename semantics can + /// check `contains` itself first). + /// + /// Rejects an invalid or reserved identifier, malformed formula, or + /// circular reference. A reference + /// to a parameter that doesn't exist *yet* is not rejected (order of + /// definition shouldn't matter, mirroring a spreadsheet); it only + /// surfaces as [`ParamError::UndefinedReference`] when [`Self::resolve`] + /// actually needs that value. + pub fn set(&mut self, name: &str, source: &str) -> Result<(), ParamError> { + if !is_valid_name(name) || is_reserved_name(name) { + return Err(ParamError::InvalidName(name.to_string())); + } + let expr = parse(source)?; + self.check_no_cycle(name, &expr)?; + match self.index_of(name) { + Some(i) => { + self.parameters[i].source = source.to_string(); + self.parameters[i].expr = expr; + } + None => self.parameters.push(Parameter { + name: name.to_string(), + source: source.to_string(), + expr, + }), + } + Ok(()) + } + + /// Removes a parameter by name. Returns whether one was actually + /// removed. Deliberately does not check whether another parameter's + /// formula still references `name` — that becomes an + /// [`ParamError::UndefinedReference`] for the referencing parameter the + /// next time it's resolved, the same graceful-degradation treatment an + /// unresolvable reference gets everywhere else in this module, rather + /// than a special "can't delete, still in use" rule this stage doesn't + /// need to invent (a future UI stage may still want to warn before + /// deleting — that's a UI-layer decision, not a data-layer one). + pub fn remove(&mut self, name: &str) -> bool { + match self.index_of(name) { + Some(i) => { + self.parameters.remove(i); + true + } + None => false, + } + } + + /// Would accepting `new_expr` as `name`'s formula close a circular + /// reference right now? Walks the dependency graph from each of + /// `new_expr`'s direct references, following each visited parameter's + /// *existing* stored formula, looking for a path back to `name`. A + /// reference to a name not currently in the table just terminates that + /// branch — no cycle through something that doesn't exist yet. + fn check_no_cycle(&self, name: &str, new_expr: &Expr) -> Result<(), ParamError> { + let mut direct_refs = Vec::new(); + new_expr.refs(&mut direct_refs); + for start in &direct_refs { + let mut visiting = Vec::new(); + if let Some(mut path) = self.find_path(start, name, &mut visiting) { + let mut full = vec![name.to_string()]; + full.append(&mut path); + return Err(ParamError::Cycle(full)); + } + } + Ok(()) + } + + /// Depth-first search from `current` to `target`, following each + /// visited parameter's existing formula's references. Returns the path + /// (starting with `current`) if `target` is reachable. + fn find_path( + &self, + current: &str, + target: &str, + visiting: &mut Vec, + ) -> Option> { + if current == target { + return Some(vec![current.to_string()]); + } + if visiting.iter().any(|v| v == current) { + // A cycle that doesn't involve `target` shouldn't be reachable + // (`set` refuses to create one), but bail out rather than loop + // forever if that invariant is ever violated some other way + // (e.g. a foreign/hand-edited file deserialized straight into a + // table without going through `set`). + return None; + } + let param = self.get(current)?; + visiting.push(current.to_string()); + let mut refs = Vec::new(); + param.expr.refs(&mut refs); + for r in &refs { + if let Some(mut rest) = self.find_path(r, target, visiting) { + let mut path = vec![current.to_string()]; + path.append(&mut rest); + visiting.pop(); + return Some(path); + } + } + visiting.pop(); + None + } + + /// Resolves one parameter's numeric value and dependency chain using a + /// fresh memo cache. + pub fn resolve(&self, name: &str) -> Result { + let mut cache = HashMap::new(); + let mut stack = Vec::new(); + self.resolve_inner(name, &mut cache, &mut stack) + } + + /// Resolves every parameter, isolating a failure to just the parameters + /// it actually affects rather than aborting the whole table — the same + /// don't-let-one-bad-item-take-down-everything-else treatment this + /// codebase already gives dangling constraints and similar derived-data + /// failures elsewhere. + pub fn resolve_all(&self) -> HashMap> { + let mut out = HashMap::with_capacity(self.parameters.len()); + for param in &self.parameters { + out.insert(param.name.clone(), self.resolve(¶m.name)); + } + out + } + + fn resolve_inner( + &self, + name: &str, + cache: &mut HashMap, + stack: &mut Vec, + ) -> Result { + if let Some(v) = cache.get(name) { + return Ok(*v); + } + if stack.iter().any(|s| s == name) { + // Defense in depth only — `set` already refuses to create a + // cycle; see `find_path`'s doc comment for how one could still + // end up here. + let mut cyc = stack.clone(); + cyc.push(name.to_string()); + return Err(ParamError::Cycle(cyc)); + } + let param = self + .get(name) + .ok_or_else(|| ParamError::UndefinedReference(name.to_string()))?; + stack.push(name.to_string()); + let value = self.eval(¶m.expr, cache, stack)?; + stack.pop(); + cache.insert(name.to_string(), value); + Ok(value) + } + + fn eval( + &self, + expr: &Expr, + cache: &mut HashMap, + stack: &mut Vec, + ) -> Result { + Ok(match expr { + Expr::Number(n) => *n, + Expr::Ref(name) => self.resolve_inner(name, cache, stack)?, + Expr::Neg(a) => -self.eval(a, cache, stack)?, + Expr::Add(a, b) => self.eval(a, cache, stack)? + self.eval(b, cache, stack)?, + Expr::Sub(a, b) => self.eval(a, cache, stack)? - self.eval(b, cache, stack)?, + Expr::Mul(a, b) => self.eval(a, cache, stack)? * self.eval(b, cache, stack)?, + Expr::Div(a, b) => { + let denom = self.eval(b, cache, stack)?; + if denom == 0.0 { + return Err(ParamError::DivisionByZero); + } + self.eval(a, cache, stack)? / denom + } + Expr::Pow(a, b) => self + .eval(a, cache, stack)? + .powf(self.eval(b, cache, stack)?), + Expr::Call(fname, args) => { + let mut values = Vec::with_capacity(args.len()); + for arg in args { + values.push(self.eval(arg, cache, stack)?); + } + call_builtin(fname, &values)? + } + }) + } +} + +impl Scene { + /// The document's named-parameter table. Use + /// [`Scene::named_parameters_mut`] to + /// define/redefine/remove a parameter. + pub fn named_parameters(&self) -> &ParameterTable { + &self.named_parameters + } + + pub fn named_parameters_mut(&mut self) -> &mut ParameterTable { + &mut self.named_parameters + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_arithmetic_with_ordinary_precedence() { + // 2 + 3 * 4 == 14, not 20 -- proves * binds tighter than +. + assert_eq!(eval_literal("2 + 3 * 4"), 14.0); + // (2 + 3) * 4 == 20 -- parens override precedence. + assert_eq!(eval_literal("(2 + 3) * 4"), 20.0); + // 2 ^ 3 ^ 2 == 2 ^ (3 ^ 2) == 512 -- right-associative power. + assert_eq!(eval_literal("2 ^ 3 ^ 2"), 512.0); + // Unary minus binds tighter than power's left operand is *read*, + // but -2^2 must still equal -(2^2) = -4 under this grammar (unary + // parses before power), matching common calculator convention. + assert_eq!(eval_literal("-2^2"), -4.0); + assert_eq!(eval_literal("2^-2"), 0.25); + assert_eq!(eval_literal("10 / 4"), 2.5); + assert_eq!(eval_literal("-(3 + 4)"), -7.0); + } + + fn eval_literal(src: &str) -> f64 { + let table = ParameterTable::new(); + let expr = parse(src).unwrap_or_else(|e| panic!("parse '{src}': {e}")); + table + .eval(&expr, &mut HashMap::new(), &mut Vec::new()) + .unwrap_or_else(|e| panic!("eval '{src}': {e}")) + } + + #[test] + fn parses_scientific_notation() { + assert_eq!(eval_literal("1e3"), 1000.0); + assert_eq!(eval_literal("1.5e-2"), 0.015); + } + + #[test] + fn rejects_malformed_formulas() { + assert!(matches!(parse(""), Err(ParamError::Parse(_)))); + assert!(matches!(parse("1 +"), Err(ParamError::Parse(_)))); + assert!(matches!(parse("(1 + 2"), Err(ParamError::Parse(_)))); + assert!(matches!(parse("1 2"), Err(ParamError::Parse(_)))); + assert!(matches!(parse("1 @ 2"), Err(ParamError::Parse(_)))); + } + + #[test] + fn builtin_functions_evaluate_and_check_arity() { + assert_eq!(eval_literal("sqrt(16)"), 4.0); + assert_eq!(eval_literal("abs(-5)"), 5.0); + assert_eq!(eval_literal("min(3, 7)"), 3.0); + assert_eq!(eval_literal("max(3, 7)"), 7.0); + assert_eq!(eval_literal("pow(2, 10)"), 1024.0); + + let table = ParameterTable::new(); + let expr = parse("sqrt(1, 2)").unwrap(); + let err = table + .eval(&expr, &mut HashMap::new(), &mut Vec::new()) + .unwrap_err(); + assert_eq!( + err, + ParamError::WrongArgCount { + function: "sqrt".to_string(), + expected: 1, + found: 2 + } + ); + + let expr = parse("bogus(1)").unwrap(); + let err = table + .eval(&expr, &mut HashMap::new(), &mut Vec::new()) + .unwrap_err(); + assert_eq!(err, ParamError::UnknownFunction("bogus".to_string())); + } + + #[test] + fn division_by_zero_is_reported_not_panicked() { + let table = ParameterTable::new(); + let expr = parse("1 / 0").unwrap(); + assert_eq!( + table.eval(&expr, &mut HashMap::new(), &mut Vec::new()), + Err(ParamError::DivisionByZero) + ); + } + + #[test] + fn set_rejects_invalid_and_reserved_names() { + let mut table = ParameterTable::new(); + assert!(matches!( + table.set("2bad", "1"), + Err(ParamError::InvalidName(_)) + )); + assert!(matches!( + table.set("has space", "1"), + Err(ParamError::InvalidName(_)) + )); + assert!(matches!( + table.set("", "1"), + Err(ParamError::InvalidName(_)) + )); + assert!(matches!( + table.set("sqrt", "1"), + Err(ParamError::InvalidName(_)) + )); + assert!(table.set("_valid1", "1").is_ok()); + } + + #[test] + fn a_literal_parameter_resolves_to_its_number() { + let mut table = ParameterTable::new(); + table.set("plate_len", "42.5").unwrap(); + assert_eq!(table.resolve("plate_len"), Ok(42.5)); + } + + #[test] + fn a_formula_resolves_through_a_reference_chain() { + let mut table = ParameterTable::new(); + table.set("hole_dia", "5").unwrap(); + table.set("hole_spacing", "2 * hole_dia + 1.5").unwrap(); + table.set("row_span", "3 * hole_spacing").unwrap(); + assert_eq!(table.resolve("hole_spacing"), Ok(11.5)); + assert_eq!(table.resolve("row_span"), Ok(34.5)); + } + + #[test] + fn resolving_an_undefined_reference_reports_which_name_is_missing() { + let mut table = ParameterTable::new(); + table.set("a", "b + 1").unwrap(); + assert_eq!( + table.resolve("a"), + Err(ParamError::UndefinedReference("b".to_string())) + ); + } + + #[test] + fn set_rejects_a_direct_self_reference() { + let mut table = ParameterTable::new(); + let err = table.set("a", "a + 1").unwrap_err(); + assert!(matches!(err, ParamError::Cycle(_))); + assert!( + !table.contains("a"), + "a rejected formula must not be stored" + ); + } + + #[test] + fn set_rejects_an_indirect_cycle_at_the_definition_that_closes_it() { + let mut table = ParameterTable::new(); + table.set("a", "b").unwrap(); + // b doesn't exist yet -- not a cycle, a is just unresolved for now. + assert_eq!( + table.resolve("a"), + Err(ParamError::UndefinedReference("b".to_string())) + ); + // Defining b = a closes the loop right here and must be rejected, + // leaving b undefined rather than silently accepted. + let err = table.set("b", "a").unwrap_err(); + assert!(matches!(err, ParamError::Cycle(_))); + assert!(!table.contains("b")); + // a's formula (still "b") is untouched -- it stays unresolved, not + // corrupted by the rejected attempt. + assert_eq!( + table.resolve("a"), + Err(ParamError::UndefinedReference("b".to_string())) + ); + } + + #[test] + fn set_allows_redefining_an_existing_parameter_to_break_a_would_be_cycle() { + let mut table = ParameterTable::new(); + table.set("a", "1").unwrap(); + table.set("b", "a + 1").unwrap(); + // Redefining a in terms of b must be rejected (a <- b <- a)... + assert!(table.set("a", "b + 1").is_err()); + // ...but redefining a to something unrelated is always fine. + table.set("a", "5").unwrap(); + assert_eq!(table.resolve("a"), Ok(5.0)); + assert_eq!(table.resolve("b"), Ok(6.0)); + } + + #[test] + fn resolve_all_isolates_a_failure_to_the_affected_parameters_only() { + let mut table = ParameterTable::new(); + table.set("good", "10").unwrap(); + table.set("bad", "missing + 1").unwrap(); + table.set("depends_on_bad", "bad * 2").unwrap(); + + let results = table.resolve_all(); + assert_eq!(results.get("good"), Some(&Ok(10.0))); + assert_eq!( + results.get("bad"), + Some(&Err(ParamError::UndefinedReference("missing".to_string()))) + ); + assert_eq!( + results.get("depends_on_bad"), + Some(&Err(ParamError::UndefinedReference("missing".to_string()))) + ); + } + + #[test] + fn remove_drops_a_parameter_and_leaves_dependents_undefined_not_panicking() { + let mut table = ParameterTable::new(); + table.set("a", "1").unwrap(); + table.set("b", "a + 1").unwrap(); + assert!(table.remove("a")); + assert!( + !table.remove("a"), + "removing an already-removed name reports false" + ); + assert_eq!( + table.resolve("b"), + Err(ParamError::UndefinedReference("a".to_string())) + ); + } + + #[test] + fn set_upserts_an_existing_parameters_formula() { + let mut table = ParameterTable::new(); + table.set("a", "1").unwrap(); + table.set("a", "2").unwrap(); + assert_eq!(table.len(), 1, "redefining must not create a second entry"); + assert_eq!(table.resolve("a"), Ok(2.0)); + } + + #[test] + fn table_round_trips_through_bincode() { + let mut table = ParameterTable::new(); + table.set("hole_dia", "5").unwrap(); + table.set("hole_spacing", "2 * hole_dia + 1.5").unwrap(); + + let bytes = bincode::serialize(&table).expect("serialize"); + let restored: ParameterTable = bincode::deserialize(&bytes).expect("deserialize"); + + assert_eq!(restored.len(), 2); + assert_eq!(restored.resolve("hole_spacing"), Ok(11.5)); + assert_eq!(restored.get("hole_dia").unwrap().source, "5"); + } + + #[test] + fn iter_preserves_insertion_order_across_redefinitions() { + let mut table = ParameterTable::new(); + table.set("z", "1").unwrap(); + table.set("a", "2").unwrap(); + table.set("z", "3").unwrap(); // redefine, should not move to the end + let names: Vec<&str> = table.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, vec!["z", "a"]); + } +} diff --git a/src/scene/named_parameters_persist.rs b/src/scene/named_parameters_persist.rs new file mode 100644 index 000000000..986827ccb --- /dev/null +++ b/src/scene/named_parameters_persist.rs @@ -0,0 +1,210 @@ +//! 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/pick/grip.rs b/src/scene/pick/grip.rs index 623eedaff..135122a04 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -238,8 +238,10 @@ pub fn find_hit_grip( /// Project an f64 world point with an explicit relative-to-eye `(view_rot, /// eye)` pair — the camera-less form of `Camera::project`. Used by the /// in-viewport editing path, which supplies a *composed* model→screen view -/// (see `Scene::composed_viewport_view`) instead of a real camera. -fn project_rte(world: DVec3, view_rot: Mat4, eye: DVec3, bounds: Rectangle) -> Option { +/// (see `Scene::composed_viewport_view`) instead of a real camera. `pub(crate)` +/// so any other single-point world→screen projection (e.g. constraint-glyph +/// anchors) can reuse it instead of re-deriving the same NDC math. +pub(crate) fn project_rte(world: DVec3, view_rot: Mat4, eye: DVec3, bounds: Rectangle) -> Option { let rel = (world - eye).as_vec3(); let clip = view_rot * rel.extend(1.0); if clip.w.abs() < 1e-9 { diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 42e6e8888..a98c7e119 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -735,7 +735,10 @@ impl Scene { PropValue::Stepper { .. } | PropValue::ColorVaries | PropValue::LwVaries - | PropValue::FieldLwVaries { .. } => continue, + | PropValue::FieldLwVaries { .. } + | PropValue::EntityLink { .. } + | PropValue::ParamRow { .. } + | PropValue::ParamAddRow => continue, } }; out.push(choice(prop.field, prop.label, editor)); @@ -883,7 +886,10 @@ impl Scene { PropValue::Stepper { display, .. } => display, PropValue::ColorVaries | PropValue::LwVaries - | PropValue::FieldLwVaries { .. } => return None, + | PropValue::FieldLwVaries { .. } + | PropValue::EntityLink { .. } + | PropValue::ParamRow { .. } + | PropValue::ParamAddRow => return None, }) } } diff --git a/src/scene/sketch_constraints.rs b/src/scene/sketch_constraints.rs new file mode 100644 index 000000000..fbf35c0a0 --- /dev/null +++ b/src/scene/sketch_constraints.rs @@ -0,0 +1,834 @@ +//! Persistent sketch-constraint data types 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. +/// +/// Reuses the GsMarker convention `AssocDimensionReference::main_gs_marker` +/// already carries for associative-dimension endpoints +/// (`src/scene/dimension_assoc.rs`), rather than inventing a second +/// sub-element addressing scheme: `marker` indexes into +/// [`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 { + pub entity: Handle, + /// `None` addresses the entity as a whole — what a Radius, Length, or + /// whole-curve constraint (Parallel, Perpendicular, Equal, Horizontal, + /// Vertical) needs; a point-level constraint (Coincident, Distance + /// between two points, Angle at a shared vertex) sets `Some(marker)`. + pub marker: Option, +} + +impl SketchRef { + pub fn whole(entity: Handle) -> Self { + Self { + entity, + marker: None, + } + } + + pub fn point(entity: Handle, marker: i32) -> Self { + Self { + entity, + marker: Some(marker), + } + } + + /// The circle/arc-center special case (`marker == -3`), broken out as + /// its own constructor since `-3` alone reads as a magic number + /// everywhere it would otherwise appear. + pub fn center(entity: Handle) -> Self { + Self { + entity, + marker: Some(-3), + } + } +} + +/// 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 +/// doc §2). Named and grouped the same way the existing one-shot ribbon +/// tools are (`crate::modules::draw::constrain::tools`), plus the +/// endpoint-picking kinds (`Coincident`, `Radius`, `Tangent`) that one-shot +/// group never needed. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum ConstraintKind { + Coincident, + Horizontal, + Vertical, + Parallel, + Perpendicular, + Equal, + /// Distance between two points, or a single line's length, or a + /// circle/arc's diameter — which reading applies depends on `refs`' + /// shape, mirroring how `DistanceConstraintCommand` + /// (`src/modules/draw/constrain/value.rs`) already infers it from the + /// selected entity. + Distance, + Angle, + Radius, + Tangent, + /// Two circles/arcs share a center — `refs`: `[center(a), center(b)]`. + /// Solves identically to `Coincident` (`sketch_solve.rs` broadens that + /// match arm rather than duplicating it) — only the DWG-native class + /// name (`ACCONCENTRICCONSTRAINT` vs `ACPOINTCOINCIDENCECONSTRAINT`) + /// and the UI entry point differ. + Concentric, + /// A point sits at a circle/arc's center — `refs`: `[point, center(circle)]`. + /// Same solver math as `Coincident`/`Concentric`, different DWG class + /// name (`ACCENTERPOINTCONSTRAINT`). + CenterPoint, + /// Two lines share the same infinite line — `refs`: `[whole(a), whole(b)]`. + Colinear, + /// A point sits at another line's midpoint — `refs`: `[point, whole(line)]`. + Midpoint, + /// Locks a whole entity at its current position — `refs`: `[whole(entity)]`. + /// No `driving_param`: the target is the entity's own live geometry at + /// solve time, not a typed value (see `sketch_solve.rs`'s `Fixed` arm). + Fixed, + /// A point lies anywhere along a line's or circle's curve (not + /// restricted to an endpoint/center) — `refs`: `[point, whole(entity)]`. + PointOnCurve, + /// The distance between one point pair equals the distance between + /// another — `refs`: `[p1, p2, p3, p4]` (`dist(p1,p2) == dist(p3,p4)`). + /// For the supported entity types, equal curvature is already covered + /// by the circle/circle radius branch of `Equal`. + EqualDistance, + /// Two circles/arcs are mirror images of each other across a line — + /// `refs`: `[center(a), center(b), whole(mirror_line)]`. Scoped to the + /// circle-center-pair case (unambiguous with whole-entity selection); + /// point-symmetry about a point, and symmetry between two lines, aren't + /// modeled. + Symmetric, + /// A circle/arc's diameter (twice `Radius`'s target) — `refs`: + /// `[whole(circle_or_arc)]`. Same DWG class as `Radius` + /// (`ACRADIUSDIAMETERCONSTRAINT`), distinguished only by the + /// `RadiusDiameterConstrType` mode byte + /// (`dwg_native_constraints.rs`), rather than a distinct object type. + Diameter, + /// The X-only (resp. Y-only) component of the distance between two + /// points — `refs`: `[p1, p2]`, same shape as `Distance`. Same DWG + /// class as `Distance` (`ACDISTANCECONSTRAINT`) with its + /// `DirectionType` set to a fixed `(1,0,0)`/`(0,1,0)` direction. + DistanceX, + DistanceY, + /// A line perpendicular to a circle/arc's tangent at their point of + /// contact — `refs`: `[whole(a), whole(b)]`, either order. For the + /// Line/Circle-only entity model this is equivalent to "the line + /// passes through the circle's center" (a circle's radius is always + /// normal to its own tangent), so it solves via the same `PointOnLine` + /// primitive `PointOnCurve` already uses. Distinct from + /// `Perpendicular` (line-to-line only). Line-Line has no meaning here + /// (that's plain `Perpendicular`) and isn't buildable. + Normal, + /// An arc's arc length (`radius * sweep angle`) — `refs`: + /// `[whole(arc)]`. Solves against the arc's own `start_angle`/ + /// `end_angle`, registered separately from its center/radius + /// (`sketch_solve.rs`'s `arc_angles` cache) — no native DWG + /// representation exists for this (neither `AcExplicitConstr.h` nor + /// `acadrust`'s `AssocConstraintNodeData` has an ArcLength-shaped + /// class/variant), so it persists in this app's own XRecord format + /// only, the same "DWG can't carry everything" gap the dependency-chain + /// DXF-omission precedent already documents. + ArcLength, +} + +pub type ConstraintId = u32; + +/// One persisted constraint record: 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 { + pub id: ConstraintId, + pub kind: ConstraintKind, + 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 + /// 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, +} + +/// A constraint scope: model space or one block definition. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SketchScope { + ModelSpace, + /// A block definition's `BlockRecord` handle — matches + /// `BlockEditSession::br_handle` + /// (`src/modules/draw/modify/block_edit.rs`). + Block(Handle), +} + +impl SketchScope { + /// 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, + } + } +} + +/// Every persisted constraint for one [`SketchScope`]. Solver state is rebuilt +/// on demand and is not stored here. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SketchConstraintSet { + pub scope: SketchScope, + pub constraints: Vec, + next_id: ConstraintId, + /// 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` + /// 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 { + Self { + scope, + constraints: Vec::new(), + next_id: 0, + dof: None, + conflicts: Vec::new(), + } + } + + /// Appends a constraint, assigning it a fresh id unique within this set. + pub fn add( + &mut self, + kind: ConstraintKind, + refs: Vec, + driving_param: Option, + ) -> ConstraintId { + let id = self.next_id; + self.next_id += 1; + self.constraints.push(SketchConstraint { + id, + kind, + refs, + driving_param, + enabled: true, + }); + id + } + + /// Removes a constraint by id. Returns whether one was actually removed. + pub fn remove(&mut self, id: ConstraintId) -> bool { + let before = self.constraints.len(); + self.constraints.retain(|c| c.id != id); + self.constraints.len() != before + } + + pub fn get(&self, id: ConstraintId) -> Option<&SketchConstraint> { + self.constraints.iter().find(|c| c.id == id) + } + + /// Every enabled constraint referencing `entity`. + pub fn constraints_touching(&self, entity: Handle) -> impl Iterator { + self.constraints + .iter() + .filter(move |c| c.enabled && c.refs.iter().any(|r| r.entity == entity)) + } + + /// Drops every constraint that references `entity` and returns the + /// removed constraint ids. + pub fn remove_all_touching(&mut self, entity: Handle) -> Vec { + let (removed, kept): (Vec<_>, Vec<_>) = self + .constraints + .drain(..) + .partition(|c| c.refs.iter().any(|r| r.entity == entity)); + self.constraints = kept; + removed.into_iter().map(|c| c.id).collect() + } +} + +/// Resolves a [`SketchRef`] 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). +/// +/// Solver-side registration reads raw entity fields directly. This helper is +/// for UI-side consumers that need the current world-space position. +pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Option { + if marker == -3 { + return match entity { + acadrust::EntityType::Circle(circle) => Some(circle.center_wcs()), + acadrust::EntityType::Arc(arc) => Some(arc.center_wcs()), + _ => None, + }; + } + if marker < 0 { + return None; + } + super::dimension_assoc::source_points(entity) + .get(marker as usize) + .copied() +} + +/// Below this squared distance (1e-6 world units), two points count as +/// already coincident for [`infer_coincident_refs`]/[`nearest_sketch_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( + document: &acadrust::CadDocument, + scope: SketchScope, + world_point: Vector3, + exclude: Option, +) -> Option { + let owner = scope.owner_handle(document); + let mut best: Option<(f64, SketchRef)> = 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))); + } + }; + for candidate in document.entities() { + let common = candidate.common(); + 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()), + _ => {} + } + } + best.map(|(_, r)| r) +} + +/// For each point on `new_entity`, finds an existing entity in `scope` (other +/// than `new_handle` itself) whose corresponding point already coincides, +/// and returns a `(new, existing)` `SketchRef` pair per match — the caller +/// adds each as a `Coincident` constraint. +/// +/// Matching uses a tight world-space tolerance because this runs after the +/// snapped entity has been committed. +pub(crate) fn infer_coincident_refs( + document: &acadrust::CadDocument, + scope: SketchScope, + new_handle: Handle, + new_entity: &acadrust::EntityType, +) -> Vec<(SketchRef, SketchRef)> { + let owner = scope.owner_handle(document); + let new_points = super::dimension_assoc::source_points(new_entity); + if new_points.is_empty() { + return Vec::new(); + } + let mut pairs = Vec::new(); + for (new_marker, new_point) in new_points.iter().enumerate() { + for candidate in document.entities() { + let common = candidate.common(); + if common.handle == new_handle || common.owner_handle != owner { + continue; + } + for (marker, point) in super::dimension_assoc::source_points(candidate) + .iter() + .enumerate() + { + let dx = point.x - new_point.x; + let dy = point.y - new_point.y; + let dz = point.z - new_point.z; + if dx * dx + dy * dy + dz * dz <= COINCIDENT_EPSILON_SQ { + pairs.push(( + SketchRef::point(new_handle, new_marker as i32), + SketchRef::point(common.handle, marker as i32), + )); + break; + } + } + } + } + pairs +} + +impl ConstraintKind { + /// The short symbol a constraint glyph shows — matches the existing + /// ribbon icons (`crate::modules::draw::constrain::{tools,value}`) for + /// the kinds that have a one-click button, so the same glyph means the + /// same thing in both places. + pub fn glyph_symbol(&self) -> &'static str { + match self { + ConstraintKind::Coincident => "≡", + ConstraintKind::Horizontal => "—", + ConstraintKind::Vertical => "│", + ConstraintKind::Parallel => "∥", + ConstraintKind::Perpendicular => "⊥", + ConstraintKind::Equal => "=", + ConstraintKind::Distance => "↔", + ConstraintKind::Angle => "∠", + ConstraintKind::Radius => "R", + ConstraintKind::Tangent => "T", + ConstraintKind::Concentric => "◎", + ConstraintKind::CenterPoint => "⊕", + ConstraintKind::Colinear => "L", + ConstraintKind::Midpoint => "M", + ConstraintKind::Fixed => "F", + ConstraintKind::PointOnCurve => "∈", + ConstraintKind::EqualDistance => "≐", + ConstraintKind::Symmetric => "S", + ConstraintKind::Diameter => "⌀", + ConstraintKind::DistanceX => "↔ₓ", + ConstraintKind::DistanceY => "↔ᵧ", + ConstraintKind::Normal => "⊾", + ConstraintKind::ArcLength => "⌢", + } + } +} + +/// The full glyph text for one constraint: its symbol, plus the driving +/// value for a dimensional kind (Distance/Angle/Radius). +pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String { + match (constraint.kind, &constraint.driving_param) { + (ConstraintKind::Angle, Some(DrivingValue::Literal(value))) => { + format!("{} {value:.1}°", constraint.kind.glyph_symbol()) + } + (_, Some(DrivingValue::Literal(value))) => { + format!("{} {value:.2}", constraint.kind.glyph_symbol()) + } + // A named reference has no single resolved number to show without + // threading `ParameterTable` into every glyph-render call site + // (`src/ui/overlay.rs`) — showing the name itself is enough for now; + // stage 4's parameters panel is the natural place to reconsider this + // once a named `driving_param` can actually be authored through the + // UI (nothing can yet — this arm exists so the match is exhaustive + // and correct ahead of that UI, not because it's reachable today). + (_, Some(DrivingValue::Named(name))) => { + format!("{} {name}", constraint.kind.glyph_symbol()) + } + (_, None) => constraint.kind.glyph_symbol().to_string(), + } +} + +/// World-space point to anchor `constraint`'s glyph at, or `None` if it +/// can't be resolved (dangling ref, unsupported entity/marker). A point ref +/// resolves straight through [`resolve_point`]; a whole-entity ref falls +/// back to a representative point on the entity (a line's midpoint, a +/// circle's rightmost point) since there's no single named point to anchor +/// on otherwise. +pub(crate) fn glyph_anchor( + document: &acadrust::CadDocument, + constraint: &SketchConstraint, +) -> Option { + let r = constraint.refs.first()?; + let entity = document.get_entity(r.entity)?; + if let Some(marker) = r.marker { + return resolve_point(entity, marker); + } + match entity { + acadrust::EntityType::Line(l) => Some(Vector3::new( + (l.start.x + l.end.x) * 0.5, + (l.start.y + l.end.y) * 0.5, + (l.start.z + l.end.z) * 0.5, + )), + acadrust::EntityType::Circle(c) => Some(c.point_at_angle_wcs(0.0)), + _ => None, + } +} + +impl super::Scene { + /// 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) + } + + /// The constraint set for `scope`, creating an empty one on first use. + /// The `pub(crate)` `sketch_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 { + if let Some(index) = self + .sketch_constraints + .iter() + .position(|s| s.scope == scope) + { + &mut self.sketch_constraints[index] + } else { + self.sketch_constraints + .push(SketchConstraintSet::new(scope)); + self.sketch_constraints.last_mut().expect("just pushed") + } + } + + /// Handle remapping lives in each command that duplicates + /// entities — `Scene::copy_entities`' `handle_map` (COPY/ARRAY/MIRROR, + /// `src/scene/modify.rs`) and `OpenCADStudio::finalize_paste`'s own + /// (clipboard paste, `src/app/command_driver.rs`) — rather than in one + /// shared table, so each call site passes its own `handle_map` here + /// after adding the duplicated entities. + /// + /// For every enabled constraint whose *every* referenced entity was + /// duplicated (a constraint straddling a duplicated and a + /// non-duplicated entity can't sensibly follow — only one side moved), + /// adds an equivalent constraint over the new handles to the same + /// scope, then triggers a solve for the newly duplicated geometry the + /// same way any other edit would. A no-op when `handle_map` is empty or + /// nothing constrained was duplicated. + pub fn duplicate_sketch_constraints_for( + &mut self, + handle_map: &rustc_hash::FxHashMap, + ) { + 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() { + for c in &set.constraints { + if !c.enabled || !c.refs.iter().all(|r| handle_map.contains_key(&r.entity)) { + continue; + } + let new_refs: Vec = c + .refs + .iter() + .map(|r| SketchRef { + entity: handle_map[&r.entity], + marker: r.marker, + }) + .collect(); + to_add.push((scope_index, c.kind, new_refs, c.driving_param.clone())); + } + } + if to_add.is_empty() { + return; + } + 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); + } + touched.sort(); + touched.dedup(); + let changes: Vec<(Handle, super::ChangeKind)> = touched + .into_iter() + .map(|h| (h, super::ChangeKind::Modified)) + .collect(); + self.bump_entities(&changes); + } + + /// Every persistent constraint, in any scope, currently driven by the + /// named parameter `name` — what the Named Parameters panel's "used by" + /// column shows. `entities` is the constraint's own referenced handles + /// (deduplicated; a two-point constraint on the same entity's own two + /// markers would otherwise list it twice), not resolved against the + /// live document — a caller wanting an entity's current type/position + /// still needs `Scene::document.get_entity`. + pub fn parameter_usage(&self, name: &str) -> Vec { + let mut out = Vec::new(); + for set in &self.sketch_constraints { + for c in &set.constraints { + let Some(DrivingValue::Named(n)) = &c.driving_param else { + continue; + }; + if n != name { + continue; + } + let mut entities: Vec = c.refs.iter().map(|r| r.entity).collect(); + entities.sort(); + entities.dedup(); + out.push(ParameterUsage { + scope: set.scope, + constraint_id: c.id, + kind: c.kind, + entities, + }); + } + } + out + } +} + +/// One persistent constraint driven by a named parameter — [`Scene::parameter_usage`]'s +/// result type. +#[derive(Debug, Clone)] +pub struct ParameterUsage { + pub scope: SketchScope, + pub constraint_id: ConstraintId, + pub kind: ConstraintKind, + pub entities: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn h(v: u64) -> Handle { + Handle::new(v) + } + + #[test] + fn add_assigns_increasing_ids_and_get_finds_them() { + let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let a = set.add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(h(1))], + None, + ); + let b = set.add( + ConstraintKind::Distance, + vec![SketchRef::whole(h(1))], + Some(DrivingValue::Literal(25.0)), + ); + assert_ne!(a, b); + assert_eq!(set.get(a).unwrap().kind, ConstraintKind::Horizontal); + assert_eq!( + set.get(b).unwrap().driving_param, + Some(DrivingValue::Literal(25.0)) + ); + } + + #[test] + fn remove_drops_only_the_matching_id() { + let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let a = set.add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(h(1))], + None, + ); + let b = set.add(ConstraintKind::Vertical, vec![SketchRef::whole(h(2))], None); + assert!(set.remove(a)); + assert!( + !set.remove(a), + "removing twice should report nothing removed the second time" + ); + assert!(set.get(a).is_none()); + assert!(set.get(b).is_some()); + } + + #[test] + fn constraints_touching_finds_entity_regardless_of_marker() { + let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + set.add( + ConstraintKind::Coincident, + vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], + None, + ); + set.add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(h(3))], + None, + ); + + let touching_1: Vec<_> = set.constraints_touching(h(1)).collect(); + assert_eq!(touching_1.len(), 1); + let touching_3: Vec<_> = set.constraints_touching(h(3)).collect(); + assert_eq!(touching_3.len(), 1); + assert_eq!(set.constraints_touching(h(99)).count(), 0); + } + + #[test] + fn constraints_touching_skips_disabled() { + let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let id = set.add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(h(1))], + None, + ); + set.constraints + .iter_mut() + .find(|c| c.id == id) + .unwrap() + .enabled = false; + assert_eq!(set.constraints_touching(h(1)).count(), 0); + } + + #[test] + fn remove_all_touching_drops_every_constraint_referencing_the_entity() { + let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let coincident = set.add( + ConstraintKind::Coincident, + vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], + None, + ); + let horizontal_other = set.add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(h(3))], + None, + ); + + let removed = set.remove_all_touching(h(1)); + assert_eq!(removed, vec![coincident]); + assert!(set.get(coincident).is_none()); + assert!( + set.get(horizontal_other).is_some(), + "unrelated entity's constraint must survive" + ); + } + + #[test] + fn scope_owner_handle_resolves_block_directly() { + let block_handle = h(42); + let scope = SketchScope::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)); + assert_eq!( + r, + SketchRef { + entity: h(7), + marker: Some(-3) + } + ); + } + + #[test] + fn sketch_constraint_set_round_trips_through_bincode() { + let mut set = SketchConstraintSet::new(SketchScope::Block(h(5))); + set.add( + ConstraintKind::Coincident, + vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], + None, + ); + set.add( + ConstraintKind::Distance, + vec![SketchRef::whole(h(3))], + Some(DrivingValue::Literal(12.5)), + ); + + 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) + .add( + ConstraintKind::Distance, + vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], + Some(DrivingValue::Named("gap".to_string())), + ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Radius, + vec![SketchRef::whole(h(2))], + Some(DrivingValue::Named("gap".to_string())), + ); + // Unrelated: a literal-driven constraint and one driven by a + // different name must not show up. + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Radius, + vec![SketchRef::whole(h(3))], + Some(DrivingValue::Literal(5.0)), + ); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![SketchRef::point(h(4), 0), SketchRef::point(h(4), 1)], + Some(DrivingValue::Named("other".to_string())), + ); + + let usage = scene.parameter_usage("gap"); + assert_eq!( + usage.len(), + 2, + "exactly the two constraints driven by 'gap', got {usage:?}" + ); + assert!(usage + .iter() + .any(|u| u.kind == ConstraintKind::Distance && u.entities == vec![h(1)])); + assert!(usage + .iter() + .any(|u| u.kind == ConstraintKind::Radius && u.entities == vec![h(2)])); + + assert_eq!(scene.parameter_usage("nonexistent").len(), 0); + } + + #[test] + fn parameter_usage_searches_every_scope_not_just_model_space() { + let mut scene = super::super::Scene::new(); + let block = h(99); + scene + .sketch_constraint_set_mut(SketchScope::Block(block)) + .add( + ConstraintKind::Radius, + vec![SketchRef::whole(h(1))], + Some(DrivingValue::Named("r".to_string())), + ); + let usage = scene.parameter_usage("r"); + assert_eq!(usage.len(), 1); + assert_eq!(usage[0].scope, SketchScope::Block(block)); + } + + #[test] + fn parameter_usage_deduplicates_an_entity_referenced_by_two_markers() { + let mut scene = super::super::Scene::new(); + // A Distance constraint whose two points are both on the same + // entity (e.g. a line's own start and end) must list that entity + // once, not twice. + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], + Some(DrivingValue::Named("len".to_string())), + ); + let usage = scene.parameter_usage("len"); + assert_eq!(usage.len(), 1); + assert_eq!(usage[0].entities, vec![h(1)]); + } +} diff --git a/src/scene/sketch_persist.rs b/src/scene/sketch_persist.rs new file mode 100644 index 000000000..0d76c66b1 --- /dev/null +++ b/src/scene/sketch_persist.rs @@ -0,0 +1,391 @@ +//! 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 { + let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { + continue; + }; + // Concatenate every Chunk entry in order, not just the first — + // a set materialized past `MAX_CHUNK_BYTES` spans more than one + // same-code `310` entry (see that constant's doc comment). + let mut bytes = Vec::new(); + for entry in &record.entries { + if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value { + if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES { + bytes.clear(); + break; + } + bytes.extend_from_slice(chunk); + } + } + if bytes.is_empty() { + continue; + } + if let Some(set) = decode(&bytes) { + 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/scene/sketch_solve.rs b/src/scene/sketch_solve.rs new file mode 100644 index 000000000..75e867cbb --- /dev/null +++ b/src/scene/sketch_solve.rs @@ -0,0 +1,1481 @@ +//! Builds and solves persistent two-dimensional sketch constraints through the geometry kernel. +//! Unsupported entity shapes, invalid target values, and incompatible planes are skipped. + +use std::collections::HashMap; +use std::rc::Rc; + +use acadrust::entities::EntityType; +use acadrust::types::Handle; + +use cadkernel_constraints::constraints::angle_distance::L2LAngle; +use cadkernel_constraints::constraints::circle_arc::{ + ArcLength, C2LDistance, P2CDistance, TangentCircumf, +}; +use cadkernel_constraints::constraints::curve_generic::CurveValue; +use cadkernel_constraints::constraints::point_line::{ + CenterOfGravity, Difference, Equal, EqualLineLength, MidpointOnLine, P2PDistance, + Parallel as ParallelConstraint, Perpendicular as PerpendicularConstraint, PointOnLine, +}; +use cadkernel_constraints::constraints::Constraint; +use cadkernel_constraints::geo::{ + Arc as GArc, Circle as GCircle, Ellipse as GEllipse, Line as GLine, Point as GPoint, +}; +use cadkernel_constraints::solvers::dogleg::solve_dl; +use cadkernel_constraints::system::System; + +use super::named_parameters::ParameterTable; +use super::sketch_constraints::{ + ConstraintId, ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef, +}; +use super::{ChangeKind, Scene}; + +/// One referenced entity's geometry, registered into an `cadkernel_constraints::System`'s +/// parameter store. +#[derive(Clone, Copy)] +enum EntityGeom { + Line(GLine), + Circle(GCircle), + /// Full `geo::Arc` — center/radius (via `.circle`), `start_angle`/ + /// `end_angle`, and real `start`/`end` points kept consistent with + /// those by the arc-rules `CurveValue` constraints `solve_scope` adds + /// for every registered arc. See this module's doc comment. + Arc(GArc), + /// Center-and-shape support only — see `register_entity`'s `Ellipse` + /// arm for the acadrust-to-`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). + Ellipse(GEllipse), +} + +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 + /// read side). + fn point_for_marker(&self, marker: i32) -> Option { + match (self, marker) { + (EntityGeom::Line(l), 0) => Some(l.p1), + (EntityGeom::Line(l), 1) => Some(l.p2), + (EntityGeom::Circle(c), -3) => Some(c.center), + (EntityGeom::Arc(a), 0) => Some(a.start), + (EntityGeom::Arc(a), 1) => Some(a.end), + (EntityGeom::Arc(a), -3) => Some(a.circle.center), + (EntityGeom::Ellipse(e), -3) => Some(e.center), + _ => None, + } + } +} + +/// `Tangent`/`Normal` only ever care about "a whole circle-shaped curve" or +/// "a whole line" — an `Arc` collapses to its `.circle` here exactly like +/// `whole_circle` does, so those two constraints' own inline matches don't +/// need every Circle/Arc combination spelled out separately. +enum CircleOrLine { + Circle(GCircle), + Line(GLine), + Other, +} + +fn as_circle_or_line(g: EntityGeom) -> CircleOrLine { + match g { + EntityGeom::Circle(c) => CircleOrLine::Circle(c), + EntityGeom::Arc(a) => CircleOrLine::Circle(a.circle), + EntityGeom::Line(l) => CircleOrLine::Line(l), + EntityGeom::Ellipse(_) => CircleOrLine::Other, + } +} + +/// Reads `handle`'s live geometry and registers it as fresh, free (never +/// `driven`) parameters in `sys` — every referenced entity is solved for, +/// nothing is a priori fixed; per the 2D kernel, an under- +/// constrained scope simply converges to the nearest configuration to its +/// current one, which is the correct behavior for a persistent system where +/// "what stays put" is a property of how many constraints exist, not a +/// convention about which entity was picked first (contrast the one-shot +/// `constrain::apply_*` commands, which do fix the first-picked entity — +/// they solve exactly one constraint in isolation, so they need that +/// convention; a whole scope's constraint graph does not). +fn register_entity( + document: &acadrust::CadDocument, + sys: &mut System, + handle: Handle, +) -> Option { + match document.get_entity(handle)? { + EntityType::Line(l) => { + let p1 = GPoint::new( + sys.add_param(l.start.x, false), + sys.add_param(l.start.y, false), + ); + let p2 = GPoint::new(sys.add_param(l.end.x, false), sys.add_param(l.end.y, false)); + Some(EntityGeom::Line(GLine { p1, p2 })) + } + EntityType::Circle(c) => { + let center = GPoint::new( + sys.add_param(c.center.x, false), + sys.add_param(c.center.y, false), + ); + let rad = sys.add_param(c.radius, false); + Some(EntityGeom::Circle(GCircle { center, rad })) + } + EntityType::Arc(a) => { + // Full registration: center/radius, both angles, and real + // start/end points seeded from acadrust's own `start_point`/ + // `end_point` (already consistent with center/radius/angle at + // registration time) — `solve_scope` adds the arc-rules + // constraints that keep them that way under solving. See this + // module's doc comment. + let center = GPoint::new( + sys.add_param(a.center.x, false), + sys.add_param(a.center.y, false), + ); + let rad = sys.add_param(a.radius, false); + let start_angle = sys.add_param(a.start_angle, false); + let end_angle = sys.add_param(a.end_angle, false); + let start_seed = a.start_point(); + let end_seed = a.end_point(); + let start = GPoint::new( + sys.add_param(start_seed.x, false), + sys.add_param(start_seed.y, false), + ); + let end = GPoint::new( + sys.add_param(end_seed.x, false), + sys.add_param(end_seed.y, false), + ); + Some(EntityGeom::Arc(GArc { + circle: GCircle { center, rad }, + start, + end, + start_angle, + end_angle, + })) + } + EntityType::Ellipse(el) => { + // acadrust's `Ellipse` is center + major-axis vector (its length + // is the major radius) + minor/major ratio; `cadkernel_constraints::geo:: + // Ellipse` is center + one focus + minor radius. Converting: + // minor_radius = major_radius * ratio, then the focus distance + // c follows from a² = b² + c² (standard ellipse identity), and + // focus1 sits `c` along the major-axis direction from center. + let major_radius = el.major_axis.length(); + let minor_radius = major_radius * el.minor_axis_ratio; + let focus_dist = (major_radius * major_radius - minor_radius * minor_radius) + .max(0.0) + .sqrt(); + // Direction is meaningless once major_radius is ~0 (a + // degenerate point-ellipse) — arbitrarily fall back to +X + // rather than dividing by ~0 in `normalize`. + let unit_major = if major_radius > 1e-9 { + el.major_axis.normalize() + } else { + acadrust::types::Vector3::UNIT_X + }; + let focus1_point = el.center + unit_major * focus_dist; + let center = GPoint::new( + sys.add_param(el.center.x, false), + sys.add_param(el.center.y, false), + ); + let focus1 = GPoint::new( + sys.add_param(focus1_point.x, false), + sys.add_param(focus1_point.y, false), + ); + let radmin = sys.add_param(minor_radius, false); + Some(EntityGeom::Ellipse(GEllipse { + center, + focus1, + radmin, + })) + } + _ => None, + } +} + +/// Resolves one [`SketchRef`] 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. +fn resolve_ref( + document: &acadrust::CadDocument, + sys: &mut System, + cache: &mut HashMap, + r: SketchRef, +) -> Option { + if let Some(&geometry) = cache.get(&r.entity) { + return Some(geometry); + } + let geometry = register_entity(document, sys, r.entity)?; + cache.insert(r.entity, geometry); + Some(geometry) +} + +const PLANE_EPS: f64 = 1e-9; + +fn is_world_z(normal: acadrust::types::Vector3) -> bool { + normal.x.abs() <= PLANE_EPS + && normal.y.abs() <= PLANE_EPS + && (normal.z - 1.0).abs() <= PLANE_EPS +} + +/// Returns the world-XY plane elevation supported by the current 2D bridge. +fn entity_plane_z(entity: &EntityType) -> Option { + let z = match entity { + EntityType::Line(line) + if is_world_z(line.normal) && (line.start.z - line.end.z).abs() <= PLANE_EPS => + { + line.start.z + } + EntityType::Circle(circle) if is_world_z(circle.normal) => circle.center.z, + EntityType::Arc(arc) if is_world_z(arc.normal) => arc.center.z, + EntityType::Ellipse(ellipse) + if is_world_z(ellipse.normal) && ellipse.major_axis.z.abs() <= PLANE_EPS => + { + ellipse.center.z + } + _ => return None, + }; + z.is_finite().then_some(z) +} + +fn refs_share_supported_plane(document: &acadrust::CadDocument, refs: &[SketchRef]) -> bool { + let mut plane_z = None; + for reference in refs { + let Some(entity) = document.get_entity(reference.entity) else { + return false; + }; + let Some(z) = entity_plane_z(entity) else { + return false; + }; + if plane_z.is_some_and(|expected: f64| (expected - z).abs() > PLANE_EPS) { + return false; + } + plane_z = Some(z); + } + plane_z.is_some() +} + +fn resolved_target(params: &ParameterTable, constraint: &SketchConstraint) -> Option { + let value = constraint.driving_param.as_ref()?.resolve(params).ok()?; + if !value.is_finite() { + return None; + } + let must_be_positive = matches!( + constraint.kind, + ConstraintKind::Distance + | ConstraintKind::Radius + | ConstraintKind::Diameter + | ConstraintKind::ArcLength + ); + (!must_be_positive || value > 0.0).then_some(value) +} + +/// One [`SketchConstraint`]'s `cadkernel_constraints` construction: every system-level +/// constraint it contributes (most kinds contribute exactly one; a few — +/// `Coincident`/`Concentric`/`CenterPoint`'s X+Y halves, `Colinear`'s two +/// endpoints, `Midpoint`'s X+Y, `Symmetric`'s midpoint+perpendicular pair, +/// `Fixed`'s per-coordinate pins — contribute more). Empty means it can't be +/// built (an unsupported/not-yet-mapped kind, a dangling ref, a ref shape +/// the kind doesn't expect — e.g. `Parallel` needs two whole-line refs — or, +/// for a dimensional kind, a `driving_param` that fails to resolve). A constraint +/// that can't be built is simply skipped for this solve, not an error: +/// geometry it would have constrained is left alone, matching how a +/// dangling associative-dimension reference degrades today rather than +/// aborting the whole recompute. (A named-parameter *cycle* should be +/// unreachable here — `ParameterTable::set` already refuses to create one — +/// so it gets the same treatment as any other resolve failure rather than a +/// special case.) +fn build_constraint( + document: &acadrust::CadDocument, + sys: &mut System, + cache: &mut HashMap, + params: &ParameterTable, + c: &SketchConstraint, +) -> 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| match resolve_ref( + document, sys, cache, r, + )? { + EntityGeom::Line(l) => Some(l), + EntityGeom::Circle(_) | EntityGeom::Arc(_) | EntityGeom::Ellipse(_) => None, + }; + let whole_circle = + |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref( + document, sys, cache, r, + )? { + EntityGeom::Circle(circ) => Some(circ), + EntityGeom::Arc(a) => Some(a.circle), + EntityGeom::Line(_) | EntityGeom::Ellipse(_) => None, + }; + let point_ref = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| { + let marker = r.marker?; + resolve_ref(document, sys, cache, r)?.point_for_marker(marker) + }; + + // `Coincident`/`Concentric`/`CenterPoint` all solve identically — two + // points (an endpoint, a circle's center via the existing `-3` marker, + // or a plain point) held equal on both axes. Only the DWG-native class + // name and the UI entry point that produces their `refs` differ. + let point_pair_equal = |sys: &mut System, + cache: &mut HashMap<_, _>, + refs: &[SketchRef]| + -> Vec> { + let [a, b] = refs else { return Vec::new() }; + let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) else { + return Vec::new(); + }; + vec![ + Rc::new(Equal::new(pa.x, pb.x, 1.0)), + Rc::new(Equal::new(pa.y, pb.y, 1.0)), + ] + }; + + match c.kind { + ConstraintKind::Coincident | 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::Parallel => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(fixed), Some(moving)) = + (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) + else { + return Vec::new(); + }; + vec![Rc::new(ParallelConstraint::new(sys.store(), moving, fixed))] + } + ConstraintKind::Perpendicular => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(fixed), Some(moving)) = + (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) + else { + return Vec::new(); + }; + vec![Rc::new(PerpendicularConstraint::new( + sys.store(), + moving, + fixed, + ))] + } + ConstraintKind::Equal => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + if let (Some(la), Some(lb)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) { + return vec![Rc::new(EqualLineLength::new(lb, la))]; + } + let (Some(ca), Some(cb)) = (whole_circle(sys, cache, *a), whole_circle(sys, cache, *b)) + else { + return Vec::new(); + }; + vec![Rc::new(Equal::new(cb.rad, ca.rad, 1.0))] + } + ConstraintKind::EqualDistance => { + let [a, b, c2, d] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(pa), Some(pb), Some(pc), Some(pd)) = ( + point_ref(sys, cache, *a), + point_ref(sys, cache, *b), + point_ref(sys, cache, *c2), + point_ref(sys, cache, *d), + ) else { + return Vec::new(); + }; + vec![Rc::new(EqualLineLength::new( + GLine { p1: pc, p2: pd }, + GLine { p1: pa, p2: pb }, + ))] + } + ConstraintKind::Colinear => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(onto), Some(moving)) = + (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) + else { + return Vec::new(); + }; + // Pinning both of `moving`'s endpoints onto `onto`'s infinite + // line forces the two to coincide (as long as `moving`'s own + // two points stay distinct) — no dedicated "colinear" primitive + // needed, `PointOnLine` applied twice does it. + vec![ + Rc::new(PointOnLine::new(moving.p1, onto)), + Rc::new(PointOnLine::new(moving.p2, onto)), + ] + } + ConstraintKind::Midpoint => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(p), Some(l)) = (point_ref(sys, cache, *a), whole_line(sys, cache, *b)) else { + return Vec::new(); + }; + vec![ + Rc::new(CenterOfGravity::new( + p.x, + vec![l.p1.x, l.p2.x], + vec![0.5, 0.5], + )), + Rc::new(CenterOfGravity::new( + p.y, + vec![l.p1.y, l.p2.y], + vec![0.5, 0.5], + )), + ] + } + ConstraintKind::PointOnCurve => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(p), Some(geom)) = ( + point_ref(sys, cache, *a), + resolve_ref(document, sys, cache, *b), + ) else { + return Vec::new(); + }; + match geom { + EntityGeom::Line(l) => vec![Rc::new(PointOnLine::new(p, l))], + EntityGeom::Circle(circ) => { + let zero = sys.add_param(0.0, true); + vec![Rc::new(P2CDistance::new(circ, p, zero))] + } + // Same "distance to the underlying circle is zero" relation + // as the `Circle` arm — doesn't restrict the point to + // within the arc's own sweep, matching that same + // pre-existing simplification for `Circle`. + EntityGeom::Arc(a) => { + let zero = sys.add_param(0.0, true); + vec![Rc::new(P2CDistance::new(a.circle, p, zero))] + } + // `PointOnEllipse` (`cadkernel_constraints::constraints::conic`) isn't + // wired up yet — deliberately deferred alongside + // ellipse-tangency, same as this module's doc comment. + EntityGeom::Ellipse(_) => Vec::new(), + } + } + ConstraintKind::Symmetric => { + let [a, b, m] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(pa), Some(pb), Some(mirror)) = ( + point_ref(sys, cache, *a), + point_ref(sys, cache, *b), + whole_line(sys, cache, *m), + ) else { + return Vec::new(); + }; + let pair = GLine { p1: pa, p2: pb }; + vec![ + Rc::new(MidpointOnLine::new(pair, mirror)), + Rc::new(PerpendicularConstraint::new(sys.store(), pair, mirror)), + ] + } + ConstraintKind::Fixed => { + let Some(r) = c.refs.first() else { + return Vec::new(); + }; + let Some(geom) = resolve_ref(document, sys, cache, *r) else { + return Vec::new(); + }; + match geom { + EntityGeom::Line(l) => { + let (x1, y1, x2, y2) = { + let store = sys.store(); + ( + store.get(l.p1.x), + store.get(l.p1.y), + store.get(l.p2.x), + store.get(l.p2.y), + ) + }; + vec![ + Rc::new(Equal::new(l.p1.x, sys.add_param(x1, true), 1.0)), + Rc::new(Equal::new(l.p1.y, sys.add_param(y1, true), 1.0)), + Rc::new(Equal::new(l.p2.x, sys.add_param(x2, true), 1.0)), + Rc::new(Equal::new(l.p2.y, sys.add_param(y2, true), 1.0)), + ] + } + EntityGeom::Circle(circ) => { + let (cx, cy, r) = { + let store = sys.store(); + ( + store.get(circ.center.x), + store.get(circ.center.y), + store.get(circ.rad), + ) + }; + vec![ + Rc::new(Equal::new(circ.center.x, sys.add_param(cx, true), 1.0)), + Rc::new(Equal::new(circ.center.y, sys.add_param(cy, true), 1.0)), + Rc::new(Equal::new(circ.rad, sys.add_param(r, true), 1.0)), + ] + } + // Pinning the 5 intrinsic params (center, radius, both + // angles) is enough — `start`/`end` are already tied to + // those via the arc-rules `CurveValue` constraints + // `solve_scope` adds for every arc, so pinning them too + // would just be redundant. + EntityGeom::Arc(a) => { + let (cx, cy, r, sa, ea) = { + let store = sys.store(); + ( + store.get(a.circle.center.x), + store.get(a.circle.center.y), + store.get(a.circle.rad), + store.get(a.start_angle), + store.get(a.end_angle), + ) + }; + vec![ + Rc::new(Equal::new(a.circle.center.x, sys.add_param(cx, true), 1.0)), + Rc::new(Equal::new(a.circle.center.y, sys.add_param(cy, true), 1.0)), + Rc::new(Equal::new(a.circle.rad, sys.add_param(r, true), 1.0)), + Rc::new(Equal::new(a.start_angle, sys.add_param(sa, true), 1.0)), + Rc::new(Equal::new(a.end_angle, sys.add_param(ea, true), 1.0)), + ] + } + // Pinning center + radmin is enough — `focus1` is already + // tied to `center` by the ellipse-rules `Difference` + // constraints `solve_scope` adds for every registered + // ellipse (see this module's doc comment), so with `center` + // pinned here too, pinning `focus1` again would just be + // redundant (same reasoning as Arc's `Fixed` arm above). + EntityGeom::Ellipse(el) => { + let (cx, cy, b) = { + let store = sys.store(); + ( + store.get(el.center.x), + store.get(el.center.y), + store.get(el.radmin), + ) + }; + vec![ + Rc::new(Equal::new(el.center.x, sys.add_param(cx, true), 1.0)), + Rc::new(Equal::new(el.center.y, sys.add_param(cy, true), 1.0)), + Rc::new(Equal::new(el.radmin, sys.add_param(b, true), 1.0)), + ] + } + } + } + ConstraintKind::Distance => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) + else { + return Vec::new(); + }; + let Some(resolved) = resolved_target(params, c) else { + return Vec::new(); + }; + let target = sys.add_param(resolved, true); + vec![Rc::new(P2PDistance::new(pa, pb, target))] + } + ConstraintKind::Angle => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(fixed), Some(moving)) = + (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) + else { + return Vec::new(); + }; + let Some(resolved) = resolved_target(params, c) else { + return Vec::new(); + }; + let angle = sys.add_param(resolved.to_radians(), true); + vec![Rc::new(L2LAngle::new(fixed, moving, angle))] + } + ConstraintKind::Radius => { + let Some(r) = c.refs.first() else { + return Vec::new(); + }; + let Some(circle) = whole_circle(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(Equal::new(circle.rad, target, 1.0))] + } + // Same math as `Radius`, just `radius = target / 2` instead of + // `radius = target` — `Equal`'s `ratio` param already supports + // this, matching how the native format stores Diameter as the same + // `ACRADIUSDIAMETERCONSTRAINT` class with a different mode byte + // rather than a distinct one (`dwg_native_constraints.rs`). + ConstraintKind::Diameter => { + let Some(r) = c.refs.first() else { + return Vec::new(); + }; + let Some(circle) = whole_circle(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(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`). + ConstraintKind::DistanceX => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) + else { + return Vec::new(); + }; + let Some(resolved) = resolved_target(params, c) else { + return Vec::new(); + }; + let target = sys.add_param(resolved, true); + vec![Rc::new(Difference::new(pa.x, pb.x, target))] + } + ConstraintKind::DistanceY => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) + else { + return Vec::new(); + }; + let Some(resolved) = resolved_target(params, c) else { + return Vec::new(); + }; + let target = sys.add_param(resolved, true); + vec![Rc::new(Difference::new(pa.y, pb.y, target))] + } + ConstraintKind::Tangent => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(ga), Some(gb)) = ( + resolve_ref(document, sys, cache, *a), + resolve_ref(document, sys, cache, *b), + ) else { + return Vec::new(); + }; + // An `Arc`'s `.circle` behaves identically to a plain `Circle` + // for tangency math — normalize both refs down first so the + // match below doesn't need every Circle/Arc combination + // written out separately. + match (as_circle_or_line(ga), as_circle_or_line(gb)) { + (CircleOrLine::Circle(c1), CircleOrLine::Circle(c2)) => { + let store = sys.store(); + let (x1, y1, r1) = ( + store.get(c1.center.x), + store.get(c1.center.y), + store.get(c1.rad), + ); + let (x2, y2, r2) = ( + store.get(c2.center.x), + store.get(c2.center.y), + store.get(c2.rad), + ); + let center_dist = ((x1 - x2).powi(2) + (y1 - y2).powi(2)).sqrt(); + // One circle already sits inside the other's span, rather + // than the two side by side — pick internal tangency so + // the first solve doesn't have to cross the singularity + // between the two tangency configurations. + let internal = center_dist < (r1 - r2).abs(); + vec![Rc::new(TangentCircumf::new( + c1.center, c2.center, c1.rad, c2.rad, internal, + ))] + } + (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) + | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { + let store = sys.store(); + let (cx, cy) = (store.get(circ.center.x), store.get(circ.center.y)); + let (x1, y1) = (store.get(line.p1.x), store.get(line.p1.y)); + let (x2, y2) = (store.get(line.p2.x), store.get(line.p2.y)); + // Signed area of (p2-p1) × (center-p1) — same formula + // `C2LDistance::signed_value` itself uses — so `ccw`'s + // sign matches whichever side the circle already sits on. + let area = (x2 - x1) * (cy - y1) - (y2 - y1) * (cx - x1); + let ccw = area >= 0.0; + // A driven, fixed zero: with `internal: false` this makes + // `C2LDistance`'s target exactly the circle's own radius + // (see its `error_grad`), i.e. plain tangency rather than + // an offset distance. + 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). + _ => Vec::new(), + } + } + // A circle's radius is always normal to its own tangent, so + // "line normal to circle/arc" reduces to "line passes through the + // circle's center" — the same `PointOnLine` primitive + // `PointOnCurve` already uses for a point-on-line case. + ConstraintKind::Normal => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(ga), Some(gb)) = ( + resolve_ref(document, sys, cache, *a), + resolve_ref(document, sys, cache, *b), + ) else { + return Vec::new(); + }; + match (as_circle_or_line(ga), as_circle_or_line(gb)) { + (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) + | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { + vec![Rc::new(PointOnLine::new(circ.center, line))] + } + // Line-Line has no meaning here (that's `Perpendicular`); + // Circle-Circle "normal" (orthogonal circles) needs a + // different, not-yet-implemented relation. + _ => Vec::new(), + } + } + ConstraintKind::ArcLength => { + let Some(r) = c.refs.first() else { + return Vec::new(); + }; + let Some(EntityGeom::Arc(arc)) = resolve_ref(document, sys, cache, *r) else { + return Vec::new(); + }; + let Some(resolved) = resolved_target(params, c) else { + return Vec::new(); + }; + let target = sys.add_param(resolved, true); + vec![Rc::new(ArcLength::new(arc, target))] + } + } +} + +const MOVE_EPS: f64 = 1e-9; +type SolveResult = ( + Vec<(Handle, EntityType)>, + usize, + Vec<( + ConstraintId, + cadkernel_constraints::diagnosis::RedundancyKind, + )>, +); + +/// Rebuilds `set`'s entire `cadkernel_constraints::System` from current document +/// geometry, solves it, and returns the resulting entity states for every +/// handle whose registered coordinates actually moved beyond floating-point +/// noise, plus the scope's total remaining degrees of freedom and any +/// redundant/conflicting constraints, resolved +/// back to the `ConstraintId`s a `ConflictResolverPanel` can name and offer +/// to remove. `None` if nothing in the scope could be built (no constraint +/// resolved to anything). DOF/conflicts are still returned even when every +/// subsystem fails to solve (rank is a structural property of the Jacobian, +/// not of whether Dogleg converged) — only the geometry write-back is gated +/// on a successful solve. +fn solve_scope( + document: &acadrust::CadDocument, + params: &ParameterTable, + set: &SketchConstraintSet, +) -> Option { + 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 + // `cadkernel_constraints::diagnosis`) are local to that partition's own constraint + // list, not `set.constraints`' indices, and several kinds contribute + // more than one system-level constraint per `SketchConstraint` (see + // `build_constraint`'s doc comment). `Rc::ptr_eq` against this after + // partitioning resolves a row back to the `ConstraintId` the UI + // actually names. + let mut owner: Vec<(Rc, ConstraintId)> = Vec::new(); + + for c in &set.constraints { + for constraint in build_constraint(document, &mut sys, &mut cache, params, c) { + owner.push((constraint.clone(), c.id)); + sys.add_constraint(constraint); + } + } + + if cache.is_empty() { + return None; + } + + // 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 + // its endpoint. Not tracked in `owner`: these are solver-internal + // bookkeeping, never redundant with anything a user-facing constraint + // could name, so there's no `ConstraintId` for them to report against. + for geom in cache.values() { + let EntityGeom::Arc(arc) = geom else { continue }; + let curve = Rc::new(*arc); + sys.add_constraint(Rc::new(CurveValue::new( + arc.start, + arc.start.x, + curve.clone(), + arc.start_angle, + ))); + sys.add_constraint(Rc::new(CurveValue::new( + arc.start, + arc.start.y, + curve.clone(), + arc.start_angle, + ))); + sys.add_constraint(Rc::new(CurveValue::new( + arc.end, + arc.end.x, + curve.clone(), + arc.end_angle, + ))); + sys.add_constraint(Rc::new(CurveValue::new( + arc.end, + arc.end.y, + curve, + arc.end_angle, + ))); + } + + // 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 + // on `center` and nothing references `focus1`/`radmin`, the solver + // correctly leaves `focus1`'s *absolute* coordinates untouched — but + // `center` moved and `focus1` didn't, so the derived vector + // `focus1 - center` (which is what `major_axis` direction/length and + // `minor_axis_ratio` are actually computed from on write-back) changes + // anyway, making the ellipse appear to rotate and reshape even though + // no shape parameter was itself pulled on. Pinning `focus1 - center` to + // its pre-solve (seed) value forces `focus1` to translate rigidly with + // `center`, keeping orientation/eccentricity invariant unless something + // actually constrains `focus1`/`radmin` directly (only `Fixed` does + // today). `radmin` needs no equivalent rule — as a lone scalar it isn't + // coupled to `center`'s movement, so it already stays put on its own. + for geom in cache.values() { + let EntityGeom::Ellipse(el) = geom else { + continue; + }; + let (cx, cy, fx, fy) = { + let store = sys.store(); + ( + store.get(el.center.x), + store.get(el.center.y), + store.get(el.focus1.x), + store.get(el.focus1.y), + ) + }; + let dx = sys.add_param(fx - cx, true); + let dy = sys.add_param(fy - cy, true); + sys.add_constraint(Rc::new(Difference::new(el.center.x, el.focus1.x, dx))); + sys.add_constraint(Rc::new(Difference::new(el.center.y, el.focus1.y, dy))); + } + + let partitions = sys.partition(); + for sub in &partitions { + solve_dl(sub, sys.store_mut()); + } + // `System::partition` only includes parameters referenced by a + // constraint, so an otherwise registered but unconstrained coordinate (e.g. a + // line's X after only a Horizontal constraint pins its Ys) is invisible + // to `diagnose` entirely, undercounting DOF. Every such untouched free + // param is unconstrained on its own, i.e. exactly 1 DOF each — added + // back here rather than fixed in `cadkernel_constraints::diagnosis`, which correctly + // has no opinion on params outside the `SubSystem` it was handed. + let total_free: usize = cache + .values() + .map(|g| match g { + EntityGeom::Line(_) => 4, + EntityGeom::Circle(_) => 3, + // Raw param count (center×2, rad, start×2, end×2, both + // angles) — same "raw, not netted against its own + // constraints" convention as every other arm here. The four + // arc-rules `CurveValue` constraints (always present, added + // just above) already reduce this to 5 *effective* DOF through + // the normal `touched_free`/`diag.dof` accounting below, the + // same way any other constraint would. + EntityGeom::Arc(_) => 9, + EntityGeom::Ellipse(_) => 5, + }) + .sum(); + let touched_free: usize = partitions.iter().map(|s| s.p_size()).sum(); + let mut dof = total_free.saturating_sub(touched_free); + let mut conflicts: Vec<( + ConstraintId, + cadkernel_constraints::diagnosis::RedundancyKind, + )> = Vec::new(); + for sub in &partitions { + let diag = cadkernel_constraints::diagnosis::diagnose(sub, sys.store()); + dof += diag.dof; + 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. + for (row, kind) in + cadkernel_constraints::diagnosis::classify_redundant(sub, sys.store(), &diag.redundant) + { + let row_constraint = &sub.constraints()[row]; + if let Some(&(_, id)) = owner.iter().find(|(rc, _)| Rc::ptr_eq(rc, row_constraint)) { + conflicts.push((id, kind)); + } + } + } + + let store = sys.store(); + let mut results = Vec::new(); + for (&handle, geom) in &cache { + let Some(entity) = document.get_entity(handle) else { + continue; + }; + match (entity, geom) { + (EntityType::Line(l), EntityGeom::Line(g)) => { + let (x1, y1, x2, y2) = ( + store.get(g.p1.x), + store.get(g.p1.y), + store.get(g.p2.x), + store.get(g.p2.y), + ); + if (x1 - l.start.x).abs() > MOVE_EPS + || (y1 - l.start.y).abs() > MOVE_EPS + || (x2 - l.end.x).abs() > MOVE_EPS + || (y2 - l.end.y).abs() > MOVE_EPS + { + let mut updated = l.clone(); + updated.start.x = x1; + updated.start.y = y1; + updated.end.x = x2; + updated.end.y = y2; + results.push((handle, EntityType::Line(updated))); + } + } + (EntityType::Circle(c), EntityGeom::Circle(g)) => { + let (cx, cy, r) = ( + store.get(g.center.x), + store.get(g.center.y), + store.get(g.rad), + ); + if (cx - c.center.x).abs() > MOVE_EPS + || (cy - c.center.y).abs() > MOVE_EPS + || (r - c.radius).abs() > MOVE_EPS + { + let mut updated = c.clone(); + updated.center.x = cx; + updated.center.y = cy; + updated.radius = r; + results.push((handle, EntityType::Circle(updated))); + } + } + (EntityType::Arc(a), EntityGeom::Arc(g)) => { + // Center/radius/angles only — `start`/`end` are derived + // (acadrust's `Arc` has no separate stored fields for them; + // `start_point()`/`end_point()` compute them from these + // same four), so nothing further needs writing back for + // them specifically. + let (cx, cy, r) = ( + store.get(g.circle.center.x), + store.get(g.circle.center.y), + store.get(g.circle.rad), + ); + let (new_start_angle, new_end_angle) = + (store.get(g.start_angle), store.get(g.end_angle)); + if (cx - a.center.x).abs() > MOVE_EPS + || (cy - a.center.y).abs() > MOVE_EPS + || (r - a.radius).abs() > MOVE_EPS + || (new_start_angle - a.start_angle).abs() > MOVE_EPS + || (new_end_angle - a.end_angle).abs() > MOVE_EPS + { + let mut updated = a.clone(); + updated.center.x = cx; + updated.center.y = cy; + updated.radius = r; + updated.start_angle = new_start_angle; + updated.end_angle = new_end_angle; + results.push((handle, EntityType::Arc(updated))); + } + } + (EntityType::Ellipse(el), EntityGeom::Ellipse(g)) => { + // Converting back to acadrust's center + major-axis-vector + + // minor/major-ratio parametrization — the inverse of + // `register_entity`'s `Ellipse` arm. `rad_maj_at` (already + // provided by `cadkernel_constraints::geo::Ellipse`) does the a²=b²+c² + // algebra; only the major-axis *direction* needs deriving + // here, from the (possibly moved) focus relative to center. + let (cx, cy) = (store.get(g.center.x), store.get(g.center.y)); + let (fx, fy) = (store.get(g.focus1.x), store.get(g.focus1.y)); + let minor_radius = store.get(g.radmin); + let (major_radius, _) = g.rad_maj_at(store, None); + let focus_dist = ((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt(); + // Direction is ill-defined once the ellipse is (near) + // circular — keep whatever direction it already had rather + // than snapping to an arbitrary axis. + let unit_major = if focus_dist > 1e-9 { + acadrust::types::Vector3::new(fx - cx, fy - cy, 0.0).normalize() + } else { + el.major_axis.normalize() + }; + let new_major_axis = unit_major * major_radius; + let new_ratio = if major_radius > 1e-9 { + minor_radius / major_radius + } else { + 0.0 + }; + if (cx - el.center.x).abs() > MOVE_EPS + || (cy - el.center.y).abs() > MOVE_EPS + || (new_major_axis.x - el.major_axis.x).abs() > MOVE_EPS + || (new_major_axis.y - el.major_axis.y).abs() > MOVE_EPS + || (new_ratio - el.minor_axis_ratio).abs() > MOVE_EPS + { + let mut updated = el.clone(); + updated.center.x = cx; + updated.center.y = cy; + updated.major_axis = new_major_axis; + updated.minor_axis_ratio = new_ratio; + results.push((handle, EntityType::Ellipse(updated))); + } + } + _ => {} + } + } + Some((results, dof, conflicts)) +} + +impl Scene { + /// Rejects constraints the 2D kernel bridge cannot represent before they + /// are persisted or reported as applied. + pub(crate) fn validate_sketch_constraint( + &self, + kind: ConstraintKind, + refs: &[SketchRef], + driving_param: Option<&super::named_parameters::DrivingValue>, + ) -> Result<(), &'static str> { + if !refs_share_supported_plane(&self.document, refs) { + return Err( + "Constraint references must be supported entities on the same world-XY plane.", + ); + } + let validation_target = match driving_param { + Some(super::named_parameters::DrivingValue::Named(name)) + if !self.named_parameters.contains(name) => + { + Some(super::named_parameters::DrivingValue::Literal(1.0)) + } + value => value.cloned(), + }; + let candidate = SketchConstraint { + id: 0, + kind, + refs: refs.to_vec(), + driving_param: validation_target, + enabled: true, + }; + let mut system = System::new(); + let mut cache = HashMap::new(); + if build_constraint( + &self.document, + &mut system, + &mut cache, + &self.named_parameters, + &candidate, + ) + .is_empty() + { + return Err("The selected entities or target value do not support this constraint."); + } + Ok(()) + } + + /// Re-solves every constraint scope touched by `changes`. Returns + /// the resulting `(Handle, ChangeKind::Modified)` entries the same way + /// `refresh_associative_dimensions`/`_hatches` do, for `bump_entities` + /// to fold into its own `changes` vec. + pub(crate) fn refresh_sketch_constraints( + &mut self, + changes: &[(Handle, ChangeKind)], + ) -> Vec<(Handle, ChangeKind)> { + // An erased entity takes its constraints with it instead of leaving + // dangling references. Done first, + // and unconditionally over every scope (not just ones a `touched` + // check would catch), so a scope left with zero constraints after + // this doesn't attempt a pointless resolve below. + // + // 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`/ + // `document.objects` and neither of those directories ever saw this + // mutation) — one undo press now restores both the entity and its + // constraints together. + for (handle, kind) in changes { + if *kind != ChangeKind::Removed { + continue; + } + for i in 0..self.sketch_constraints.len() { + if self.sketch_constraints[i] + .constraints_touching(*handle) + .next() + .is_some() + { + let scope = self.sketch_constraints[i].scope; + let before = self.sketch_constraints[i].clone(); + self.record_undo_sketch_constraints_before(scope, before); + self.sketch_constraints[i].remove_all_touching(*handle); + } + } + } + + let mut result = Vec::new(); + for i in 0..self.sketch_constraints.len() { + let touched = changes.iter().any(|(handle, _)| { + self.sketch_constraints[i] + .constraints_touching(*handle) + .next() + .is_some() + }); + if !touched { + continue; + } + let Some((solved, dof, conflicts)) = solve_scope( + &self.document, + &self.named_parameters, + &self.sketch_constraints[i], + ) else { + continue; + }; + self.sketch_constraints[i].dof = Some(dof); + self.sketch_constraints[i].conflicts = conflicts; + for (handle, new_entity) in solved { + if let Some(before) = self.document.get_entity_arc(handle) { + self.record_undo_before(handle, Some(before)); + } + if let Some(slot) = self.document.get_entity_mut(handle) { + *slot = new_entity; + } + result.push((handle, ChangeKind::Modified)); + } + } + result + } + + /// Re-solves touched scopes during a grip drag without mutating the + /// document or recording undo. The caller owns preview write-back and the + /// final gesture history entry. + pub(crate) fn solve_sketch_constraints_preview( + &self, + touched: &[Handle], + ) -> Vec<(Handle, EntityType)> { + if self.sketch_constraints.is_empty() || touched.is_empty() { + return Vec::new(); + } + let mut result = Vec::new(); + for set in &self.sketch_constraints { + let is_touched = touched + .iter() + .any(|handle| set.constraints_touching(*handle).next().is_some()); + if !is_touched { + continue; + } + let Some((solved, _dof, _conflicts)) = + solve_scope(&self.document, &self.named_parameters, set) + else { + continue; + }; + result.extend(solved); + } + result + } +} + +#[cfg(test)] +mod tests { + use super::super::named_parameters::DrivingValue; + use super::super::sketch_constraints::{ + ConstraintKind, SketchConstraintSet, SketchRef, SketchScope, + }; + use super::Scene; + use acadrust::entities::EntityType; + use acadrust::types::Vector3; + + #[test] + fn validation_rejects_unsupported_entity_shapes_and_planes() { + let mut scene = Scene::new(); + let circle = scene.add_entity(EntityType::Circle( + acadrust::entities::Circle::from_center_radius(Vector3::ZERO, 2.0), + )); + assert!(scene + .validate_sketch_constraint( + ConstraintKind::Horizontal, + &[SketchRef::whole(circle)], + None + ) + .is_err()); + + let lower = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ))); + let upper = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 1.0), + Vector3::new(1.0, 0.0, 1.0), + ))); + assert!(scene + .validate_sketch_constraint( + ConstraintKind::Parallel, + &[SketchRef::whole(lower), SketchRef::whole(upper)], + None, + ) + .is_err()); + } + + #[test] + fn validation_enforces_dimensional_value_domains() { + let mut scene = Scene::new(); + let line = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ))); + let refs = [SketchRef::point(line, 0), SketchRef::point(line, 1)]; + assert!(scene + .validate_sketch_constraint( + ConstraintKind::Distance, + &refs, + Some(&DrivingValue::Literal(-1.0)), + ) + .is_err()); + assert!(scene + .validate_sketch_constraint( + ConstraintKind::DistanceX, + &refs, + Some(&DrivingValue::Literal(-1.0)), + ) + .is_ok()); + assert!(scene + .validate_sketch_constraint( + ConstraintKind::DistanceX, + &refs, + Some(&DrivingValue::Literal(f64::NAN)), + ) + .is_err()); + } + + // The bulk of this stage's coverage (Horizontal/Parallel/Distance/ + // Coincident solving, unrelated edits not triggering a resolve) lives + // in `tests/sketch_constraints_solve.rs`, which only needs `pub` API. + // This one test needs `record_undo_before`/`take_undo_recording` + // (`pub(crate)`), so it stays internal. + #[test] + fn one_edit_that_ripples_through_a_constraint_still_records_as_one_undo_step() { + 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 = SketchConstraintSet::new(SketchScope::ModelSpace); + set.add( + ConstraintKind::Parallel, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); + scene.sketch_constraints.push(set); + + scene.begin_undo_recording(); + // A real command (Move, grip-drag commit, ...) records its own + // before-image before mutating. + let before_a = scene.document.get_entity_arc(a); + scene.record_undo_before(a, before_a); + if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(a) { + l.end = Vector3::new(10.0, 6.0, 0.0); + } + scene.bump_entities(&[(a, super::ChangeKind::Modified)]); + let recording = scene + .take_undo_recording() + .expect("an undo recording should still be open"); + + let (entities, _objects, _sketch_constraints, _named_parameters) = + recording.into_recorded_images(); + let touched: std::collections::HashSet<_> = entities.iter().map(|(h, _)| *h).collect(); + assert!( + touched.contains(&a), + "the directly-edited line must be in the undo delta" + ); + assert!( + touched.contains(&b), + "the constraint-solved neighbor must ride the same undo delta" + ); + } + + /// Erasing an entity records the removed constraints in the same undo + /// transaction as the geometry. + #[test] + fn erasing_a_constrained_entity_records_its_scope_for_undo() { + let mut scene = Scene::new(); + 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(10.0, 0.0, 0.0), + Vector3::new(20.0, 5.0, 0.0), + ))); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Coincident, + vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], + None, + ); + assert_eq!( + scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap() + .constraints + .len(), + 1 + ); + + scene.begin_undo_recording(); + scene.erase_entities(&[a]); + + // Ensure the test exercises restoration rather than a no-op. + assert_eq!( + scene + .sketch_constraint_set(SketchScope::ModelSpace) + .map_or(0, |s| s.constraints.len()), + 0, + "the constraint touching the erased line should be gone from the live set" + ); + + let recording = scene + .take_undo_recording() + .expect("an undo recording should still be open"); + let (_entities, _objects, sketch_constraints, _named_parameters) = + recording.into_recorded_images(); + assert_eq!( + sketch_constraints.len(), + 1, + "the touched scope's before-image must be captured" + ); + let (scope, before) = &sketch_constraints[0]; + assert_eq!(*scope, SketchScope::ModelSpace); + assert_eq!( + before.constraints.len(), + 1, + "the before-image must still hold the constraint as it was before the erase" + ); + assert_eq!(before.constraints[0].kind, ConstraintKind::Coincident); + assert_eq!( + before.constraints[0].refs, + vec![SketchRef::point(a, 1), SketchRef::point(b, 0)] + ); + + // What `apply_delta_state` does with this on undo: install the + // before-image back as the live scope state. + *scene.sketch_constraint_set_mut(*scope) = before.clone(); + assert_eq!( + scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap() + .constraints + .len(), + 1, + "restoring the before-image must bring the constraint back" + ); + } + + /// `solve_sketch_constraints_preview` reports the constrained + /// neighbor's solved position without writing anything into the + /// document itself — the grip-drag caller owns applying it. + #[test] + fn preview_solve_reports_the_neighbors_new_state_without_mutating_the_document() { + 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 = SketchConstraintSet::new(SketchScope::ModelSpace); + set.add( + ConstraintKind::Parallel, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); + scene.sketch_constraints.push(set); + + // Simulate a live grip drag: mutate `a` directly (as `apply_grip` + // would each frame), without going through `bump_entities` at all. + if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(a) { + l.end = Vector3::new(10.0, 6.0, 0.0); + } + let b_before = scene.document.get_entity(b).cloned(); + + let solved = scene.solve_sketch_constraints_preview(&[a]); + + assert_eq!( + scene.document.get_entity(b), + b_before.as_ref(), + "preview must not mutate the document" + ); + // The whole scope solves together (every registered entity's params + // are free, not just `b`'s — see `register_entity`'s doc comment), + // so `a` itself may also have shifted slightly to reach the nearest + // mutually-parallel configuration; read whichever position `a` ends + // up at from `solved` too, rather than assuming it stayed exactly + // where the simulated drag put it. + let line_dir = |entity: &EntityType| { + let EntityType::Line(l) = entity else { + panic!("expected a Line") + }; + (l.end.x - l.start.x, l.end.y - l.start.y) + }; + let dir_a = solved + .iter() + .find(|(h, _)| *h == a) + .map(|(_, e)| line_dir(e)) + .unwrap_or((10.0, 6.0)); + let (_, moved_entity) = solved + .iter() + .find(|(h, _)| *h == b) + .expect("b should be reported as moved"); + let dir_b = line_dir(moved_entity); + let cross = dir_a.0 * dir_b.1 - dir_a.1 * dir_b.0; + assert!( + cross.abs() < 1e-6, + "the previewed positions should be mutually parallel: dir_a={dir_a:?} dir_b={dir_b:?}" + ); + } +} diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 3a47c1732..113057a8d 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -645,6 +645,7 @@ pub fn selection_overlay<'a>( crosshair_bg: [f32; 4], crosshair: CrosshairOptions, selection_visual: SelectionVisualOptions, + constraint_glyphs: Vec<(Point, String, bool)>, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -668,6 +669,7 @@ pub fn selection_overlay<'a>( crosshair_bg, crosshair, selection_visual, + constraint_glyphs, }) .width(Length::Fill) .height(Length::Fill) @@ -726,6 +728,8 @@ struct SelectionCanvas { crosshair_bg: [f32; 4], crosshair: CrosshairOptions, selection_visual: SelectionVisualOptions, + /// Constraint glyph anchor, label, and conflict state for the current scope. + constraint_glyphs: Vec<(Point, String, bool)>, } fn draw_grip_marker( @@ -1621,6 +1625,41 @@ 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() { + const GLYPH_SIZE: f32 = 11.0; + const GLYPH_PAD_X: f32 = 5.0; + const GLYPH_PAD_Y: f32 = 2.0; + let normal_bg = theme.palette().primary.base.color; + let normal_fg = theme.palette().primary.base.text; + // A redundant or conflicting constraint gets the danger palette + // instead of the + // ordinary primary one — same information a resolver panel + // would show, surfaced right on the geometry. + let conflict_bg = theme.palette().danger.base.color; + let conflict_fg = theme.palette().danger.base.text; + for (anchor, label, is_conflicting) in &self.constraint_glyphs { + if !anchor.x.is_finite() || !anchor.y.is_finite() { + continue; + } + let (bg, fg) = if *is_conflicting { (conflict_bg, conflict_fg) } else { (normal_bg, normal_fg) }; + // Rough width estimate (monospace-ish glyph set, short labels) + // avoids a text-measurement pass just to size the background pill. + let w = label.chars().count() as f32 * GLYPH_SIZE * 0.62 + GLYPH_PAD_X * 2.0; + let h = GLYPH_SIZE + GLYPH_PAD_Y * 2.0; + let top_left = Point::new(anchor.x - w * 0.5, anchor.y - h * 0.5); + let pill = canvas::Path::rounded_rectangle(top_left, iced::Size::new(w, h), (h * 0.5).into()); + frame.fill(&pill, bg); + frame.fill_text(canvas::Text { + content: label.clone(), + position: Point::new(top_left.x + GLYPH_PAD_X, top_left.y + GLYPH_PAD_Y), + color: fg, + size: iced::Pixels(GLYPH_SIZE), + shaping: iced::advanced::text::Shaping::Advanced, + ..Default::default() + }); + } + } // Small cross at each acquired tracking point. for ost in &self.ost_points { let tp = ost.screen; diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 9033adb99..aff5ada91 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -267,6 +267,10 @@ pub enum FieldKey { Geom(&'static str), /// A block attribute value field, keyed by its tag. Attr(String), + /// One column of one named-parameter row, keyed by its stable + /// `ParameterTable::iter()` position (see `PropValue::ParamRow`'s doc + /// comment for why index rather than name). + Param(usize, crate::ui::window::named_parameters::ParamField), } impl FieldKey { @@ -277,10 +281,21 @@ impl FieldKey { match self { FieldKey::Geom(field) => prop_geom_field_id(field), FieldKey::Attr(tag) => prop_attr_field_id(tag), + FieldKey::Param(index, field) => prop_param_field_id(*index, *field), } } } +/// Text-input widget id for one column of a named-parameter row. +pub fn prop_param_field_id(index: usize, field: crate::ui::window::named_parameters::ParamField) -> iced::widget::Id { + use crate::ui::window::named_parameters::ParamField; + let suffix = match field { + ParamField::Name => "name", + ParamField::Formula => "formula", + }; + iced::widget::Id::from(format!("props-param-field-{index}-{suffix}")) +} + /// Edit-buffer / active-field key for a block attribute value, keyed by its /// tag. Kept in one place so the live-input handler and the row renderer agree. pub fn attr_edit_key(tag: &str) -> FieldKey { @@ -338,6 +353,13 @@ pub fn build_field_key_map( if let Some(key) = key { map.insert(key.widget_id(), key); } + if let PropValue::ParamRow { index, .. } = &prop.value { + use crate::ui::window::named_parameters::ParamField; + for field in [ParamField::Name, ParamField::Formula] { + let key = FieldKey::Param(*index, field); + map.insert(key.widget_id(), key); + } + } } } map @@ -842,6 +864,13 @@ impl PropertiesPanel { self.render_hatch_pattern_row(label, current) } PropValue::AttrText { tag, value } => self.render_attr_row(tag, value), + PropValue::EntityLink { handles, conflicting } => { + render_entity_link_row(label, handles.clone(), *conflicting) + } + PropValue::ParamRow { index, name, formula, resolved } => { + self.render_param_row(*index, name, formula, resolved) + } + PropValue::ParamAddRow => render_param_add_row(), } } @@ -1367,6 +1396,95 @@ impl PropertiesPanel { prop_row_with_active(tag, ti.into(), active) } + /// One named-parameter row: editable name + formula, with the live- + /// resolved value (or error) shown alongside, embedded in the panel instead of + /// the old separate modal. Each field commits on submit (Enter / losing + /// focus), not per keystroke — `ParameterTable::set` validates + /// immediately and would otherwise reject a formula mid-type (the same + /// reasoning the old modal's buffered-Apply design used, just applied + /// per-field instead of per-whole-table). + fn render_param_row<'a>( + &'a self, + index: usize, + name: &'a str, + formula: &'a str, + resolved: &'a Result, + ) -> Element<'a, Message> { + use crate::ui::window::named_parameters::ParamField; + + let name_key = FieldKey::Param(index, ParamField::Name); + let name_display = self.edit_buf.get(&name_key).map(|s| s.as_str()).unwrap_or(name); + let name_active = self.active_field.as_ref() == Some(&name_key); + let name_input = text_input("name", name_display) + .id(name_key.widget_id()) + .on_input(move |v| Message::PropParamInput { index, field: ParamField::Name, value: v }) + .on_submit(Message::PropParamCommit { index, field: ParamField::Name }) + .size(FONT_SZ) + .style(text_input_style) + .padding([3, 6]) + .width(Length::FillPortion(3)); + + let formula_key = FieldKey::Param(index, ParamField::Formula); + let formula_display = self.edit_buf.get(&formula_key).map(|s| s.as_str()).unwrap_or(formula); + let formula_active = self.active_field.as_ref() == Some(&formula_key); + let formula_input = text_input("formula", formula_display) + .id(formula_key.widget_id()) + .on_input(move |v| Message::PropParamInput { index, field: ParamField::Formula, value: v }) + .on_submit(Message::PropParamCommit { index, field: ParamField::Formula }) + .size(FONT_SZ) + .style(text_input_style) + .padding([3, 6]) + .width(Length::FillPortion(3)); + + let (value_text, is_error) = match resolved { + Ok(v) => (format!("{v:.4}"), false), + Err(_) => ("—".to_string(), true), + }; + let value_label = container( + text(value_text).size(FONT_SZ).style(move |theme: &Theme| iced::widget::text::Style { + color: is_error.then_some(theme.palette().danger.base.color), + }), + ) + .width(Length::FillPortion(2)) + .align_x(iced::Right); + + let delete_btn = button(text("\u{2715}").size(FONT_SZ)) + .on_press(Message::PropParamDelete(index)) + .style(button::text) + .padding([2, 6]); + + let active = name_active || formula_active; + let bg = move |theme: &Theme| { + if active { + Background::Color(theme.palette().primary.weak.color) + } else { + Background::Color(theme.palette().background.base.color) + } + }; + let content = container(row![name_input, formula_input, value_label, delete_btn].spacing(4).align_y(iced::Center)) + .style(move |theme: &Theme| container::Style { background: Some(bg(theme)), ..Default::default() }) + .padding([2, 6]) + .width(Length::Fill); + + if let Err(err) = resolved { + tooltip(content, text(err.as_str()).size(FONT_SZ), tooltip::Position::Top) + .gap(4.0) + .padding(6.0) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.danger.weak.color)), + text_color: Some(palette.danger.weak.text), + border: Border { color: palette.danger.base.color, width: 1.0, radius: 4.0.into() }, + ..Default::default() + } + }) + .into() + } else { + content.into() + } + } + fn render_hatch_pattern_row<'a>( &'a self, label: &'a str, @@ -1805,6 +1923,55 @@ fn render_ro_with_tooltip_row<'a>( prop_row_widget(label, wrapped.into()) } +// ── Constraints section row (clickable entity link) ─────────────────────── + +/// One persistent-constraint row in the Constraints properties section: +/// `label` is the glyph + kind name + resolved value (e.g. "↔ Distance: +/// hole_dia = 12.00"), rendered as the row's own clickable surface — there's +/// no natural separate "value" for a constraint row, unlike a Layer/Color +/// field, so this doesn't use the usual `prop_row_widget` label|value split. +/// Clicking it selects every entity in `handles`; a conflicting/redundant +/// constraint (mirrors the viewport glyph pill's own color cue) tints red. +fn render_entity_link_row<'a>(label: &'a str, handles: Vec, conflicting: bool) -> Element<'a, Message> { + let btn = button(text(label).size(FONT_SZ).width(Length::Fill)) + .on_press(Message::PropConstraintLinkClick(handles)) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let pair = if conflicting { + palette.danger.weak + } else { + match status { + button::Status::Hovered | button::Status::Pressed => palette.background.weak, + _ => palette.background.base, + } + }; + button::Style { + background: Some(Background::Color(pair.color)), + border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into() }, + text_color: pair.text, + ..Default::default() + } + }) + .padding([3, 8]) + .width(Length::Fill); + container(btn).width(Length::Fill).into() +} + +// ── Parameters section: "+ Add parameter" row ────────────────────────────── + +/// The trailing "+ Add parameter" row: appends a fresh, uniquely-named +/// parameter (`param1`, `param2`, …) the user then renames/redefines inline +/// — avoids a separate "pending new row" concept, since every row always +/// reflects a real committed table entry. +fn render_param_add_row<'a>() -> Element<'a, Message> { + let btn = button(row![text("+").size(FONT_SZ), text(t!("Add parameter").into_owned()).size(FONT_SZ)].spacing(6).align_y(iced::Center)) + .on_press(Message::PropParamAddNew) + .style(button::text) + .padding([4, 8]) + .width(Length::Fill); + container(btn).width(Length::Fill).into() +} + /// Build a label | widget property row. fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<'a, Message> { prop_row_with_active(label, widget, false) diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index d444d8f05..6ef8bf31d 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -15,18 +15,18 @@ use crate::modules::IconKind; use crate::t; use crate::ui::{icons, wrap_bar::PosReport}; -const PANEL_ID: &str = "draw_extension"; -const GROUP_ID: &str = "draw_extension_group"; +pub(super) const PANEL_ID: &str = "draw_extension"; +const TITLE_ID: &str = "draw_extension_title"; const SCALE: f32 = 0.7; const CELL: f32 = 36.0 * SCALE; const GAP: f32 = 3.0 * SCALE; const OPTION_HEIGHT: f32 = 30.0 * SCALE; -struct Tool { - command: &'static str, - label: &'static str, - icon: &'static [u8], - options: &'static [(&'static str, &'static str)], +pub(super) struct Tool { + pub command: &'static str, + pub label: &'static str, + pub icon: &'static [u8], + pub options: &'static [(&'static str, &'static str)], } const TOOLS: &[Tool] = &[ @@ -54,16 +54,6 @@ const TOOLS: &[Tool] = &[ icon: include_bytes!("../../../assets/icons/ray.svg"), options: &[], }, - Tool { - command: "MULTIPOINT", - label: "Multiple Points", - icon: include_bytes!("../../../assets/icons/multipoint.svg"), - options: &[ - ("POINT", "Single Point"), - ("MULTIPOINT", "Multiple Points"), - ("DDPTYPE", "Point Style"), - ], - }, Tool { command: "DIVIDE", label: "Divide", @@ -106,6 +96,16 @@ const TOOLS: &[Tool] = &[ icon: include_bytes!("../../../assets/icons/donut.svg"), options: &[], }, + Tool { + command: "MULTIPOINT", + label: "Multiple Points", + icon: include_bytes!("../../../assets/icons/multipoint.svg"), + options: &[ + ("POINT", "Single Point"), + ("MULTIPOINT", "Multiple Points"), + ("DDPTYPE", "Point Style"), + ], + }, Tool { command: "REVCLOUD", label: "Revision Cloud", @@ -118,51 +118,83 @@ const TOOLS: &[Tool] = &[ }, ]; -pub(super) fn owns_dropdown(id: &str) -> bool { - id == PANEL_ID - || TOOLS - .iter() - .any(|tool| tool.command == id && !tool.options.is_empty()) +#[derive(Clone, Copy)] +struct Panel { + id: &'static str, + title_id: &'static str, + title: &'static str, + tools: &'static [Tool], } -pub(super) fn group_anchor<'a>(title: &str, content: Element<'a, Message>) -> Element<'a, Message> { - if title == "Draw" { - PosReport::new(GROUP_ID, content).into() - } else { - content - } +const PANELS: &[Panel] = &[ + Panel { + id: PANEL_ID, + title_id: TITLE_ID, + title: "Draw", + tools: TOOLS, + }, + Panel { + id: "modify_extension", + title_id: "modify_extension_title", + title: "Modify", + tools: super::modify_panel::TOOLS, + }, +]; + +fn panel_for_dropdown(id: &str) -> Option { + PANELS.iter().copied().find(|panel| { + panel.id == id + || panel + .tools + .iter() + .any(|tool| tool.command == id && !tool.options.is_empty()) + }) +} + +pub(super) fn owns_dropdown(id: &str) -> bool { + panel_for_dropdown(id).is_some() +} + +pub(super) fn parent_panel(id: &str) -> Option<&'static str> { + panel_for_dropdown(id).map(|panel| panel.id) } pub(super) fn group_title<'a>(title: &'static str, open: &Option) -> Element<'a, Message> { - if title != "Draw" || TOOLS.is_empty() { + let Some(panel) = PANELS + .iter() + .find(|panel| panel.title == title && !panel.tools.is_empty()) + else { return container(text(t!(title)).size(9).style(muted_text_style)) .padding([1, 4]) .into(); - } - let expanded = open.as_deref().is_some_and(owns_dropdown); + }; + let expanded = open.as_deref().and_then(parent_panel) == Some(panel.id); let arrow = if expanded { icons::themed_arrow_up(7.0) } else { icons::themed_arrow_down(7.0) }; PosReport::new( - PANEL_ID, + panel.id, button( - row![text(t!(title)).size(9), arrow] - .spacing(4) - .align_y(iced::Center), + row![ + PosReport::new(panel.title_id, text(t!(title)).size(9)), + arrow + ] + .spacing(4) + .align_y(iced::Center), ) - .on_press(Message::ToggleRibbonDropdown(PANEL_ID.to_string())) + .on_press(Message::ToggleRibbonDropdown(panel.id.to_string())) .style(move |theme: &Theme, status| tool_btn_style(theme, expanded, status)) .padding([1, 4]), ) .into() } -fn tool_button(tool: &Tool, active: bool) -> Element<'static, Message> { +fn tool_button(tool: &Tool, active: bool, panel_id: &'static str) -> Element<'static, Message> { let face = button(make_icon(IconKind::Svg(tool.icon), 23.0 * SCALE)) .on_press(Message::DropdownSelectItem { - dropdown_id: PANEL_ID, + dropdown_id: panel_id, cmd: tool.command, }) .style(move |theme: &Theme, status| tool_btn_style(theme, active, status)) @@ -195,20 +227,22 @@ fn tool_button(tool: &Tool, active: bool) -> Element<'static, Message> { } pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element<'a, Message> { + let panel = panel_for_dropdown(id).expect("registered ribbon extension"); + let tools = panel.tools; let width = (7.0 * (CELL + GAP) - GAP + 12.0).min((win.0 - 8.0).max(CELL + 12.0)); - let (_, x, anchor_top) = ribbon.dd_anchor(PANEL_ID, width, win.0); - let anchor = crate::ui::wrap_bar::dropdown_bounds(GROUP_ID); + let (_, x, anchor_top) = ribbon.dd_anchor(panel.id, width, win.0); + let anchor = crate::ui::wrap_bar::dropdown_bounds(panel.title_id); let left = anchor - .map_or(x, |b| b.x) + .map_or(x, |b| b.x + (b.width - width) / 2.0) .clamp(0.0, (win.0 - width).max(0.0)); let top = anchor_top.min((win.1 - 80.0).max(0.0)); let available_height = (win.1 - top - 4.0).max(1.0); let cols = (((width - 12.0 + GAP) / (CELL + GAP)).floor() as usize).clamp(1, 7); - let option_tool = TOOLS + let option_tool = tools .iter() .find(|tool| tool.command == id && !tool.options.is_empty()); let content_height = option_tool.map_or_else( - || TOOLS.len().div_ceil(cols) as f32 * (CELL + GAP) - GAP + 12.0, + || tools.len().div_ceil(cols) as f32 * (CELL + GAP) - GAP + 12.0, |tool| tool.options.len() as f32 * OPTION_HEIGHT, ); let contents: Element<'static, Message> = if let Some(tool) = option_tool { @@ -232,13 +266,17 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element .into() } else { column( - TOOLS + tools .chunks(cols) .map(|tools| { row(tools .iter() .map(|tool| { - tool_button(tool, ribbon.active_tool.as_deref() == Some(tool.command)) + tool_button( + tool, + ribbon.active_tool.as_deref() == Some(tool.command), + panel.id, + ) }) .collect::>()) .spacing(GAP) @@ -259,3 +297,29 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element }); dropdown_backdrop(position_ribbon_dropdown(panel.into(), false, left, top)) } + +#[cfg(test)] +mod tests { + use super::{parent_panel, PANEL_ID, TOOLS}; + + #[test] + fn extension_submenus_resolve_to_their_parent_panel() { + assert_eq!(parent_panel("REVCLOUD"), Some(PANEL_ID)); + assert_eq!(parent_panel("DRAWORDER_FRONT"), Some("modify_extension")); + assert_eq!(parent_panel("unknown"), None); + } + + #[test] + fn split_tools_follow_plain_tools() { + let first_split = TOOLS + .iter() + .position(|tool| !tool.options.is_empty()) + .unwrap_or(TOOLS.len()); + assert!(TOOLS[..first_split] + .iter() + .all(|tool| tool.options.is_empty())); + assert!(TOOLS[first_split..] + .iter() + .all(|tool| !tool.options.is_empty())); + } +} diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 7ffc5ec7b..5dc37ad6b 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -22,6 +22,7 @@ use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem}; mod widgets; mod draw_panel; +mod modify_panel; use widgets::{StyleContext, *}; mod collapse; use collapse::{CollapsePanels, Panel}; @@ -351,6 +352,21 @@ impl Ribbon { self.layer_filter.clear(); } + pub fn escape_extension(&mut self) -> bool { + let Some(id) = self.open_dropdown.as_deref() else { + return false; + }; + let Some(parent) = draw_panel::parent_panel(id) else { + return false; + }; + if id == parent { + self.close_dropdown(); + } else { + self.open_dropdown = Some(parent.to_string()); + } + true + } + /// Toggle the flyout of a collapsed ribbon panel (identified by its title). pub fn toggle_collapsed_panel(&mut self, id: &str) { if self.collapsed_open.as_deref() == Some(id) { @@ -1370,7 +1386,7 @@ fn render_group<'a>( r.push(e) }); - draw_panel::group_anchor(group.title, column![ + column![ tools_el, draw_panel::group_title(group.title, open_dd), ] @@ -1378,7 +1394,7 @@ fn render_group<'a>( .spacing(0) .padding([3u16, 4]) .height(Length::Fixed(TOOL_BAR_H)) - .into()) + .into() } /// The top-level command id of a ribbon item, if it has one. @@ -1584,6 +1600,17 @@ mod tests { assert!(!is_active_tool("BLOCKPALETTE", &None, &off)); } + #[test] + fn escape_returns_from_an_extension_submenu_then_closes_the_panel() { + let mut ribbon = Ribbon::default(); + ribbon.open_dropdown = Some("DRAWORDER_FRONT".to_string()); + + assert!(ribbon.escape_extension()); + assert_eq!(ribbon.open_dropdown.as_deref(), Some("modify_extension")); + assert!(ribbon.escape_extension()); + assert_eq!(ribbon.open_dropdown, None); + } + /// Reproducible element-construction benchmark for the ribbon view. Run with: /// `cargo test --lib --release -- --ignored --nocapture bench_ribbon_view_construction` /// diff --git a/src/ui/ribbon/modify_panel.rs b/src/ui/ribbon/modify_panel.rs new file mode 100644 index 000000000..835e03c27 --- /dev/null +++ b/src/ui/ribbon/modify_panel.rs @@ -0,0 +1,117 @@ +use super::draw_panel::Tool; + +pub(super) const TOOLS: &[Tool] = &[ + Tool { + command: "SETBYLAYER", + label: "Set to ByLayer", + icon: include_bytes!("../../../assets/icons/modify_setbylayer.svg"), + options: &[], + }, + Tool { + command: "LENGTHEN", + label: "Lengthen", + icon: include_bytes!("../../../assets/icons/modify_lengthen.svg"), + options: &[], + }, + Tool { + command: "PEDIT", + label: "Edit Polyline", + icon: include_bytes!("../../../assets/icons/modify_pedit.svg"), + options: &[], + }, + Tool { + command: "SPLINEDIT", + label: "Edit Spline", + icon: include_bytes!("../../../assets/icons/modify_splinedit.svg"), + options: &[], + }, + Tool { + command: "HATCHEDIT", + label: "Edit Hatch", + icon: include_bytes!("../../../assets/icons/modify_hatchedit.svg"), + options: &[], + }, + Tool { + command: "ALIGN", + label: "Align", + icon: include_bytes!("../../../assets/icons/modify_align.svg"), + options: &[], + }, + Tool { + command: "BREAK", + label: "Break", + icon: include_bytes!("../../../assets/icons/modify_break.svg"), + options: &[], + }, + Tool { + command: "BREAKATPOINT", + label: "Break at Point", + icon: include_bytes!("../../../assets/icons/modify_breakatpoint.svg"), + options: &[], + }, + Tool { + command: "JOIN", + label: "Join", + icon: include_bytes!("../../../assets/icons/modify_join.svg"), + options: &[], + }, + Tool { + command: "REVERSE", + label: "Reverse", + icon: include_bytes!("../../../assets/icons/modify_reverse.svg"), + options: &[], + }, + Tool { + command: "NCOPY", + label: "Copy Nested Objects", + icon: include_bytes!("../../../assets/icons/modify_ncopy.svg"), + options: &[], + }, + Tool { + command: "OVERKILL", + label: "Delete Duplicate Objects", + icon: include_bytes!("../../../assets/icons/modify_overkill.svg"), + options: &[], + }, + Tool { + command: "DRAWORDER_FRONT", + label: "Draw Order", + icon: include_bytes!("../../../assets/icons/modify_draworder.svg"), + options: &[ + ("DRAWORDER_FRONT", "Bring to Front"), + ("DRAWORDER_BACK", "Send to Back"), + ("DRAWORDER_ABOVE", "Bring Above Objects"), + ("DRAWORDER_UNDER", "Send Under Objects"), + ], + }, +]; + +#[cfg(test)] +mod tests { + use super::TOOLS; + + #[test] + fn every_tool_command_is_registered() { + let registered = crate::command::all_registered_command_names(); + for command in TOOLS + .iter() + .flat_map(|tool| std::iter::once(tool.command).chain(tool.options.iter().map(|o| o.0))) + { + assert!(registered.contains(&command), "{command} is not registered"); + } + } + + #[test] + fn split_tools_follow_plain_tools() { + let first_split = TOOLS + .iter() + .position(|tool| !tool.options.is_empty()) + .unwrap_or(TOOLS.len()); + assert!(TOOLS[..first_split] + .iter() + .all(|tool| tool.options.is_empty())); + assert!(TOOLS[first_split..] + .iter() + .all(|tool| !tool.options.is_empty())); + } +} diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs index 0a84008c1..296548ae5 100644 --- a/src/ui/statusbar/mod.rs +++ b/src/ui/statusbar/mod.rs @@ -125,6 +125,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. + // `None` when the scope + // has no `SketchConstraintSet` yet (nothing constrained), so the + // badge stays invisible until it's actually relevant. + sketch_dof: Option, + // Number of redundant/conflicting constraints in the current scope. + // Zero hides + // the pill entirely, so an ordinarily/fully-constrained drawing sees + // no new clutter. + sketch_conflicts: usize, ) -> Element<'a, Message> { let StatusMenuData { layout_names, @@ -243,6 +253,40 @@ impl StatusBar { .into(), ); } + // Not a `StatusPill` (so not user-hideable yet): invisible until the + // current scope actually has a SketchConstraintSet, so a drawing + // that never uses parametric constraints sees no new clutter. + if let Some(dof) = sketch_dof { + let pill = if dof == 0 { + success_pill(crate::tf!("DOF: {dof}").into_owned()) + } else { + status_pill(crate::tf!("DOF: {dof}").into_owned()) + }; + pills.push( + tip( + pill, + if dof == 0 { + t!("Fully constrained — no remaining degrees of freedom") + } else { + t!("Remaining degrees of freedom in the current sketch's persistent constraints") + }, + ) + .into(), + ); + } + // Each click removes one flagged constraint. + if sketch_conflicts > 0 { + pills.push( + tip( + action_pill( + crate::tf!("⚠ {sketch_conflicts} conflicting").into_owned(), + Message::ResolveOneSketchConflict, + ), + t!("One or more constraints in this sketch conflict or are redundant\nClick to remove one and re-solve"), + ) + .into(), + ); + } if vis(StatusPill::Ortho) { pills.push( tip( @@ -1034,6 +1078,22 @@ fn status_pill(label: impl Into) -> Element<'static, Message> { .into() } +/// A success-colored status pill used when no degrees of freedom remain. +fn success_pill(label: impl Into) -> Element<'static, Message> { + container(text(label.into()).size(12)) + .style(|theme: &Theme| { + let palette = theme.palette(); + 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() }, + ..Default::default() + } + }) + .padding([4, 8]) + .into() +} + // ── Scale popup button ──────────────────────────────────────────────────── /// Shared visual root for labelled status-bar menus (units, scale, …). diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index c4e345c8d..c43b80b7c 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -18,3 +18,4 @@ pub mod options; pub mod attribute_editor; pub mod alias_editor; pub mod find_replace; +pub mod named_parameters; diff --git a/src/ui/window/named_parameters.rs b/src/ui/window/named_parameters.rs new file mode 100644 index 000000000..35628ae0a --- /dev/null +++ b/src/ui/window/named_parameters.rs @@ -0,0 +1,435 @@ +//! Named-parameter table editor using buffered `(name, formula)` rows. +//! edited in a working buffer (`OpenCADStudio::named_parameter_editor_rows`) +//! and committed to `Scene::named_parameters` only on Apply — matching the +//! alias editor's "closing discards unapplied edits" convention, which +//! suits a formula table even better: `ParameterTable::set` validates (and +//! can reject) a formula immediately, so typing a not-yet-finished name or +//! formula must not touch the live table on every keystroke. +//! +//! Unlike the alias editor, each row also shows a live-computed resolved +//! value or error (`preview`, below) — built fresh from the whole buffer on +//! every render, not cached: the same "cheap enough to recompute from +//! scratch" philosophy `ParameterTable`/`sketch_solve.rs` already use +//! throughout this project, and it means the preview reflects circular/ +//! undefined-reference problems across rows immediately, before Apply. + +use crate::app::Message; +use crate::scene::named_parameters::ParameterTable; +use crate::scene::Scene; +use crate::t; +use acadrust::types::Handle; +use acadrust::EntityType; +use iced::widget::tooltip::Position as TipPos; +use iced::widget::{button, column, container, row, scrollable, text, text_input, tooltip, Space}; +use iced::{Background, Element, Length, Theme}; + +/// Short "Kind handle" label for an entity without a user-facing name. +fn entity_label(scene: &Scene, handle: Handle) -> String { + match scene.document.get_entity(handle) { + Some(EntityType::Line(_)) => format!("Line {handle}"), + Some(EntityType::Circle(_)) => format!("Circle {handle}"), + Some(EntityType::Arc(_)) => format!("Arc {handle}"), + Some(_) => format!("Entity {handle}"), + None => format!("(erased {handle})"), + } +} + +/// One line per constraint currently driven by `name`, e.g. +/// `"Distance: Line 0x2A, Line 0x2B"` — empty when nothing references it +/// yet (a parameter defined but not yet used by any constraint). +fn usage_lines(scene: &Scene, name: &str) -> Vec { + scene + .parameter_usage(name) + .iter() + .map(|u| { + let entities = u + .entities + .iter() + .map(|h| entity_label(scene, *h)) + .collect::>() + .join(", "); + format!("{:?}: {entities}", u.kind) + }) + .collect() +} + +/// The "Used by" column's cell for one row: a compact kind-count summary +/// (e.g. `"Distance ×2, Radius ×1"`), with the full per-constraint entity +/// list (`usage_lines`) as a hover tooltip — the column is too narrow to +/// show entity lists inline once a parameter drives more than one or two +/// constraints. A blank name (a fresh, not-yet-named row) or a name driving +/// nothing shows a muted em dash instead. +fn used_by_cell<'a>(scene: &Scene, name: &str) -> Element<'a, Message> { + if name.is_empty() { + return Space::new().into(); + } + let lines = usage_lines(scene, name); + if lines.is_empty() { + return text("—").size(11).style(muted_style).into(); + } + let mut counts: Vec<(String, usize)> = Vec::new(); + for line in &lines { + let kind = line.split(':').next().unwrap_or(line).to_string(); + match counts.iter_mut().find(|(k, _)| *k == kind) { + Some((_, n)) => *n += 1, + None => counts.push((kind, 1)), + } + } + let summary = counts + .iter() + .map(|(k, n)| format!("{k} ×{n}")) + .collect::>() + .join(", "); + let mut body = column![].spacing(2); + for line in &lines { + body = body.push(text(line.clone()).size(11)); + } + tooltip( + text(summary).size(11).style(muted_style), + container(body) + .style(container::bordered_box) + .padding([4, 8]), + TipPos::Top, + ) + .into() +} + +/// Which column of a parameter row a text edit targets. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ParamField { + Name, + Formula, +} + +/// One working-buffer row. Free text until Apply — `ParameterTable::set` +/// does the real validation then, not on every keystroke. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ParamEditorRow { + pub name: String, + pub formula: String, +} + +/// Right-hand lane reserved for the scrollbar so it never overlaps the ✕ +/// column — same convention and value as `alias_editor::GUTTER`. +const GUTTER: f32 = 16.0; + +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.palette().background.base.text.scale_alpha(0.68)), + } +} + +fn danger_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.palette().danger.base.color), + } +} + +/// Every buffer index sharing a (trimmed) name with at least one other +/// non-blank row — `ParameterTable::set` upserts by name, so two rows typed +/// with the same name would otherwise silently collapse into "whichever one +/// happens to `set` last wins," discarding the other's formula with no +/// indication anything was lost. Both `preview` and the real Apply +/// (`OpenCADStudio::apply_named_parameter_editor_rows`) check this first and +/// refuse every row in a name collision rather than guessing which one the +/// user meant. +pub(crate) fn duplicate_name_rows(rows: &[ParamEditorRow]) -> std::collections::HashSet { + let mut by_name: std::collections::HashMap<&str, Vec> = std::collections::HashMap::new(); + for (idx, row) in rows.iter().enumerate() { + let name = row.name.trim(); + if !name.is_empty() { + by_name.entry(name).or_default().push(idx); + } + } + by_name + .into_values() + .filter(|idxs| idxs.len() > 1) + .flatten() + .collect() +} + +/// Builds a scratch table from every row's *current* text and resolves each +/// row's own value against it, so the preview reflects cross-row references +/// (including a row referencing one defined further down — order in the +/// buffer doesn't matter, same as it doesn't for the real table). Returns +/// one `Result` per row, in row order: +/// - `Err` from a blank name is suppressed (`Ok` with no text shown instead, +/// handled by the caller) — a fresh row from "+ Add parameter" before the +/// user has typed a name yet isn't an error to flag. +/// - A name shared with another row is its own error (`duplicate_name_rows`) +/// before anything is even attempted. +/// - `Err` from `ParameterTable::set` itself (bad name, parse error, a +/// formula that would close a cycle right now) is that row's own problem. +/// - `Err(UndefinedReference)`/other resolve failure means the row's own +/// formula is fine but something *it* depends on isn't. +fn preview(rows: &[ParamEditorRow]) -> Vec>> { + let duplicates = duplicate_name_rows(rows); + let mut table = ParameterTable::new(); + let mut set_errors: Vec> = vec![None; rows.len()]; + for (idx, row) in rows.iter().enumerate() { + let name = row.name.trim(); + if name.is_empty() || duplicates.contains(&idx) { + continue; + } + if let Err(e) = table.set(name, row.formula.trim()) { + set_errors[idx] = Some(e.to_string()); + } + } + rows.iter() + .enumerate() + .map(|(idx, row)| { + let name = row.name.trim(); + if name.is_empty() { + return None; + } + if duplicates.contains(&idx) { + return Some(Err(format!("duplicate name '{name}'"))); + } + if let Some(err) = &set_errors[idx] { + return Some(Err(err.clone())); + } + Some(table.resolve(name).map_err(|e| e.to_string())) + }) + .collect() +} + +/// Build the named-parameter editor content. `rows` is the live working +/// buffer; `scene` supplies the "Used by" column, computed against the +/// *live*, already-applied constraint set (`Scene::parameter_usage`) — +/// unapplied edits to a row's name in the buffer don't retroactively +/// relabel what's shown, same as the rest of this editor only takes effect +/// on Apply. +pub fn view_window<'a>( + rows: &'a [ParamEditorRow], + scene: &'a Scene, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let title = text(t!("Named Parameters")).size(15); + let hint = text(t!( + "Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits." + )) + .size(11) + .style(muted_style); + + let gutter = iced::Padding { + top: 0.0, + right: GUTTER, + bottom: 0.0, + left: 0.0, + }; + const NAME_WIDTH: f32 = 120.0; + const VALUE_WIDTH: f32 = 100.0; + const USED_BY_WIDTH: f32 = 160.0; + + let head = container( + row![ + container(text(t!("Name")).size(11).style(muted_style)) + .width(Length::Fixed(NAME_WIDTH)), + container(text(t!("Formula")).size(11).style(muted_style)).width(sizing.width), + container(text(t!("Value")).size(11).style(muted_style)) + .width(Length::Fixed(VALUE_WIDTH)), + container(text(t!("Used by")).size(11).style(muted_style)) + .width(Length::Fixed(USED_BY_WIDTH)), + Space::new().width(Length::Fixed(30.0)), + ] + .spacing(8), + ) + .padding(gutter); + + let results = preview(rows); + let mut list = column![].spacing(3); + for (idx, row) in rows.iter().enumerate() { + let name_box = text_input(t!("name").as_ref(), &row.name) + .on_input(move |v| Message::NamedParametersInput { + idx, + field: ParamField::Name, + value: v, + }) + .size(13) + .padding([3, 6]) + .width(Length::Fixed(NAME_WIDTH)); + let formula_box = text_input(t!("formula").as_ref(), &row.formula) + .on_input(move |v| Message::NamedParametersInput { + idx, + field: ParamField::Formula, + value: v, + }) + .size(13) + .padding([3, 6]) + .width(sizing.width); + let value_cell: Element<'_, Message> = match results.get(idx).cloned().flatten() { + Some(Ok(value)) => { + container(text(format!("= {value:.4}")).size(12).style(muted_style)).into() + } + Some(Err(msg)) => container(text(msg).size(11).style(danger_style)).into(), + None => Space::new().into(), + }; + let used_by_cell = used_by_cell(scene, row.name.trim()); + let del = button(crate::ui::icons::themed_danger_text( + crate::ui::icons::CLOSE, + 12.0, + )) + .on_press(Message::NamedParametersRemove(idx)) + .padding([2, 6]) + .style(button::danger); + list = list.push( + row![ + name_box, + formula_box, + container(value_cell).width(Length::Fixed(VALUE_WIDTH)), + container(used_by_cell).width(Length::Fixed(USED_BY_WIDTH)), + del + ] + .spacing(8) + .align_y(iced::Center), + ); + } + + let add = button(text(t!("+ Add parameter")).size(12)) + .on_press(Message::NamedParametersAdd) + .padding([4, 10]) + .style(button::secondary); + + let apply = button(text(t!("Apply")).size(12)) + .on_press(Message::NamedParametersApply) + .padding([4, 16]) + .style(button::primary); + + container( + column![ + title, + hint, + Space::new().height(6), + head, + scrollable(container(list).padding(gutter)).height(sizing.height), + Space::new().height(6), + row![add, Space::new().width(sizing.width), apply].align_y(iced::Center), + ] + .spacing(6) + .width(sizing.width) + .height(sizing.height), + ) + .padding(12) + .width(sizing.width) + .height(sizing.height) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + ..Default::default() + }) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(name: &str, formula: &str) -> ParamEditorRow { + ParamEditorRow { + name: name.to_string(), + formula: formula.to_string(), + } + } + + #[test] + fn preview_resolves_a_literal_and_a_cross_row_reference() { + let rows = vec![ + row("hole_dia", "5"), + row("hole_spacing", "2 * hole_dia + 1.5"), + ]; + let results = preview(&rows); + assert_eq!(results[0], Some(Ok(5.0))); + assert_eq!(results[1], Some(Ok(11.5))); + } + + #[test] + fn preview_resolves_a_forward_reference_regardless_of_row_order() { + // `plate_width` (row 0) references `plate_len` (row 1) -- defined + // *after* it in the buffer. Order in the editor must not matter, same + // as it doesn't for the real `ParameterTable`. + let rows = vec![row("plate_width", "plate_len / 2"), row("plate_len", "20")]; + let results = preview(&rows); + assert_eq!(results[0], Some(Ok(10.0))); + assert_eq!(results[1], Some(Ok(20.0))); + } + + #[test] + fn preview_reports_a_cycle_on_the_row_that_closes_it() { + let rows = vec![row("a", "b"), row("b", "a")]; + let results = preview(&rows); + // Row 0 (a = b) is accepted first (b doesn't exist yet, no cycle); + // row 1 (b = a) is the one that would close the loop and is rejected. + assert!( + matches!(results[0], Some(Ok(_))) + || matches!(&results[0], Some(Err(e)) if e.contains("not defined")) + ); + assert!(matches!(&results[1], Some(Err(e)) if e.to_lowercase().contains("circular"))); + } + + #[test] + fn preview_leaves_a_blank_row_without_a_name_unflagged() { + let rows = vec![row("", "")]; + assert_eq!(preview(&rows), vec![None]); + } + + #[test] + fn preview_reports_a_malformed_formula_on_its_own_row() { + let rows = vec![row("bad", "1 +")]; + let results = preview(&rows); + assert!(matches!(&results[0], Some(Err(e)) if e.contains("formula error"))); + } + + #[test] + fn preview_flags_every_row_sharing_a_duplicate_name_instead_of_picking_one() { + let rows = vec![row("x", "1"), row("x", "2"), row("y", "3")]; + let results = preview(&rows); + assert!(matches!(&results[0], Some(Err(e)) if e.contains("duplicate"))); + assert!(matches!(&results[1], Some(Err(e)) if e.contains("duplicate"))); + assert_eq!( + results[2], + Some(Ok(3.0)), + "an unrelated row's name must not be affected" + ); + } + + #[test] + fn usage_lines_names_the_constraint_kind_and_its_entities() { + use crate::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; + let mut scene = Scene::new(); + let line = scene.add_entity(acadrust::EntityType::Line( + acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ), + )); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(crate::scene::named_parameters::DrivingValue::Named( + "gap".to_string(), + )), + ); + + let lines = usage_lines(&scene, "gap"); + assert_eq!(lines.len(), 1); + assert!(lines[0].starts_with("Distance: Line "), "got {lines:?}"); + assert!( + lines[0].contains(&format!("{line}")), + "should name the actual entity handle, got {lines:?}" + ); + } + + #[test] + fn usage_lines_is_empty_for_a_parameter_nothing_references() { + let scene = Scene::new(); + assert!(usage_lines(&scene, "unused").is_empty()); + } + + #[test] + fn entity_label_reports_erased_for_a_dangling_handle() { + let scene = Scene::new(); + let label = entity_label(&scene, acadrust::types::Handle::new(999)); + assert!(label.starts_with("(erased "), "got {label}"); + } +} diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 3c28d3700..44e0906a9 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -145,6 +145,8 @@ 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], language: crate::i18n::Language, @@ -349,6 +351,39 @@ 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) + .size(15), + text(crate::t!("Show values and parameter names on constraint markers")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!( + "On by default. Turn off to show just the constraint glyph in the viewport — \ + enough to see that a constraint is present — without the driven value or named \ + parameter text covering nearby geometry." + )) + .size(11) + .width(sizing.width), Space::new().height(22), text(crate::t!("File Safety")).size(15), Space::new().height(10), diff --git a/tests/sketch_constraints_solve.rs b/tests/sketch_constraints_solve.rs new file mode 100644 index 000000000..6741759c3 --- /dev/null +++ b/tests/sketch_constraints_solve.rs @@ -0,0 +1,1345 @@ +use acadrust::entities::EntityType; +use acadrust::types::{Handle, Vector3}; +use OpenCADStudio::scene::named_parameters::DrivingValue; +use OpenCADStudio::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; +use OpenCADStudio::scene::{ChangeKind, Scene}; + +fn add_line(scene: &mut Scene, x1: f64, y1: f64, x2: f64, y2: f64) -> Handle { + scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(x1, y1, 0.0), + Vector3::new(x2, y2, 0.0), + ))) +} + +fn line_endpoints(scene: &Scene, handle: Handle) -> (Vector3, Vector3) { + match scene.document.get_entity(handle).expect("entity exists") { + EntityType::Line(l) => (l.start, l.end), + other => panic!("expected a Line, got {other:?}"), + } +} + +fn set_line_end(scene: &mut Scene, handle: Handle, end: Vector3) { + if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(handle) { + l.end = end; + } +} + +fn add_circle(scene: &mut Scene, cx: f64, cy: f64, radius: f64) -> Handle { + scene.add_entity(EntityType::Circle( + acadrust::entities::Circle::from_center_radius(Vector3::new(cx, cy, 0.0), radius), + )) +} + +fn circle_geom(scene: &Scene, handle: Handle) -> (Vector3, f64) { + match scene.document.get_entity(handle).expect("entity exists") { + EntityType::Circle(c) => (c.center, c.radius), + other => panic!("expected a Circle, got {other:?}"), + } +} + +fn add_arc( + scene: &mut Scene, + cx: f64, + cy: f64, + radius: f64, + start_angle: f64, + end_angle: f64, +) -> Handle { + scene.add_entity(EntityType::Arc( + acadrust::entities::Arc::from_center_radius_angles( + Vector3::new(cx, cy, 0.0), + radius, + start_angle, + end_angle, + ), + )) +} + +fn arc_geom(scene: &Scene, handle: Handle) -> (Vector3, f64) { + match scene.document.get_entity(handle).expect("entity exists") { + EntityType::Arc(a) => (a.center, a.radius), + other => panic!("expected an Arc, got {other:?}"), + } +} + +fn arc_angles(scene: &Scene, handle: Handle) -> (f64, f64, f64) { + match scene.document.get_entity(handle).expect("entity exists") { + EntityType::Arc(a) => (a.radius, a.start_angle, a.end_angle), + other => panic!("expected an Arc, got {other:?}"), + } +} + +fn add_ellipse(scene: &mut Scene, cx: f64, cy: f64, major_axis: (f64, f64), ratio: f64) -> Handle { + scene.add_entity(EntityType::Ellipse( + acadrust::entities::Ellipse::from_center_axes( + Vector3::new(cx, cy, 0.0), + Vector3::new(major_axis.0, major_axis.1, 0.0), + ratio, + ), + )) +} + +fn ellipse_geom(scene: &Scene, handle: Handle) -> (Vector3, Vector3, f64) { + match scene.document.get_entity(handle).expect("entity exists") { + EntityType::Ellipse(e) => (e.center, e.major_axis, e.minor_axis_ratio), + other => panic!("expected an Ellipse, got {other:?}"), + } +} + +#[test] +fn horizontal_constraint_levels_the_line_when_an_endpoint_moves() { + let mut scene = Scene::new(); + let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(line)], + None, + ); + + // Drag the endpoint off-axis — a real edit, the kind of thing a Move + // command or grip-drag commit would do. + set_line_end(&mut scene, line, Vector3::new(10.0, 4.0, 0.0)); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let (start, end) = line_endpoints(&scene, line); + assert!( + (start.y - end.y).abs() < 1e-6, + "line should have been re-leveled: start={start:?} end={end:?}" + ); + + // A line is 4 raw coordinates; Horizontal removes exactly 1 (y1 == y2), + // leaving 3 — not 1: the two X coordinates are registered but never + // referenced by any constraint, so they must still count as free + // (regression guard for the undercounting `solve_scope` bug found while + // live-testing this stage: `System::partition`'s subsystems only see + // constraint-referenced params, so an untouched one was invisible to + // `diagnose` entirely before this was fixed). + let dof = scene + .sketch_constraint_set(SketchScope::ModelSpace) + .and_then(|s| s.dof); + assert_eq!( + dof, + Some(3), + "DOF should count the two untouched X coordinates too" + ); +} + +#[test] +fn parallel_constraint_rotates_the_other_line_when_one_moves() { + let mut scene = Scene::new(); + let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + let b = add_line(&mut scene, 0.0, 5.0, 10.0, 5.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Parallel, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); + + // Rotate `a`; `b` (parallel to it) should follow. + set_line_end(&mut scene, a, Vector3::new(10.0, 6.0, 0.0)); + scene.bump_entities(&[(a, ChangeKind::Modified)]); + + let (a1, a2) = line_endpoints(&scene, a); + let (b1, b2) = line_endpoints(&scene, b); + let dir_a = ((a2.x - a1.x), (a2.y - a1.y)); + let dir_b = ((b2.x - b1.x), (b2.y - b1.y)); + // Parallel: cross product of directions is ~0. + let cross = dir_a.0 * dir_b.1 - dir_a.1 * dir_b.0; + assert!( + cross.abs() < 1e-6, + "lines should be parallel after the solve: dir_a={dir_a:?} dir_b={dir_b:?}" + ); +} + +#[test] +fn distance_constraint_holds_the_target_length_after_an_unrelated_endpoint_edit() { + let mut scene = Scene::new(); + let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Literal(20.0)), + ); + + set_line_end(&mut scene, line, Vector3::new(5.0, 5.0, 0.0)); // arbitrary edit, not length=20 + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let (start, end) = line_endpoints(&scene, line); + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!( + (len - 20.0).abs() < 1e-6, + "length should have been solved to the driving value, got {len}" + ); +} + +/// `named_parameters_design.md` stage 3: a `Distance` constraint's driving +/// value can be a named-parameter reference instead of a literal, resolved +/// through `Scene::named_parameters` at solve time. +#[test] +fn distance_constraint_resolves_a_named_parameter_reference() { + let mut scene = Scene::new(); + let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + scene + .named_parameters_mut() + .set("target_len", "20") + .unwrap(); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Named("target_len".to_string())), + ); + + set_line_end(&mut scene, line, Vector3::new(5.0, 5.0, 0.0)); // arbitrary edit, not length=20 + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let (start, end) = line_endpoints(&scene, line); + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!( + (len - 20.0).abs() < 1e-6, + "length should have resolved the named parameter, got {len}" + ); + + // Editing the parameter itself and touching the scope again should + // ripple through, same as editing a literal driving value would. + scene.named_parameters_mut().set("target_len", "8").unwrap(); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + let (start, end) = line_endpoints(&scene, line); + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!( + (len - 8.0).abs() < 1e-6, + "length should track the redefined parameter value, got {len}" + ); +} + +/// A `driving_param` referencing a named parameter that doesn't (or no +/// longer) exists must not panic the solve — same "skip, don't panic" +/// contract `build_constraint`'s doc comment gives every other unbuildable +/// constraint. The referenced line's two endpoint coordinates are still +/// registered (via `point_ref`) even though the constraint itself can't be +/// built, so they're just left free — the edit that triggered this solve is +/// not undone or altered. +#[test] +fn distance_constraint_with_an_undefined_named_reference_is_skipped_not_panicked() { + let mut scene = Scene::new(); + let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Named("does_not_exist".to_string())), + ); + + let edited_end = Vector3::new(5.0, 5.0, 0.0); + set_line_end(&mut scene, line, edited_end); + scene.bump_entities(&[(line, ChangeKind::Modified)]); // must not panic + + let (_, end) = line_endpoints(&scene, line); + assert_eq!(end, edited_end, "an unresolvable driving reference must leave the edit alone, not move it toward a phantom target"); +} + +#[test] +fn tangent_constraint_between_a_line_and_a_circle_solves_to_touching() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene, 0.0, 0.0, 5.0); + // A horizontal line well clear of the circle (distance 10, radius 5) — + // deliberately not tangent yet. + let line = add_line(&mut scene, -10.0, 10.0, 10.0, 10.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Tangent, + vec![SketchRef::whole(circle), SketchRef::whole(line)], + None, + ); + scene.bump_entities(&[(circle, ChangeKind::Modified), (line, ChangeKind::Modified)]); + + let (center, radius) = circle_geom(&scene, circle); + let (p1, p2) = line_endpoints(&scene, line); + let line_dir = (p2.x - p1.x, p2.y - p1.y); + let line_len = (line_dir.0 * line_dir.0 + line_dir.1 * line_dir.1).sqrt(); + // Signed distance from the circle's center to the (infinite) line. + let signed_dist = (line_dir.0 * (center.y - p1.y) - line_dir.1 * (center.x - p1.x)) / line_len; + assert!((signed_dist.abs() - radius).abs() < 1e-6, "center-to-line distance should equal the radius after solving: dist={signed_dist} radius={radius}"); +} + +#[test] +fn tangent_constraint_between_two_circles_solves_to_external_tangency() { + let mut scene = Scene::new(); + let a = add_circle(&mut scene, 0.0, 0.0, 3.0); + let b = add_circle(&mut scene, 20.0, 0.0, 2.0); // far apart: distance 20, r1+r2 = 5 + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Tangent, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); + scene.bump_entities(&[(a, ChangeKind::Modified), (b, ChangeKind::Modified)]); + + let (ca, ra) = circle_geom(&scene, a); + let (cb, rb) = circle_geom(&scene, b); + let center_dist = ((ca.x - cb.x).powi(2) + (ca.y - cb.y).powi(2)).sqrt(); + assert!( + (center_dist - (ra + rb)).abs() < 1e-6, + "circles should sit exactly (r1+r2) apart after solving: dist={center_dist} r1+r2={}", + ra + rb + ); +} + +#[test] +fn coincident_constraint_pulls_the_second_point_onto_the_first_when_it_moves() { + let mut scene = Scene::new(); + let a = add_line(&mut scene, 0.0, 0.0, 5.0, 0.0); + let b = add_line(&mut scene, 5.0, 0.0, 5.0, 5.0); // b.start coincident with a.end + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Coincident, + vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], + None, + ); + + // Move a's end away — b's start should follow to stay coincident. + set_line_end(&mut scene, a, Vector3::new(8.0, 3.0, 0.0)); + scene.bump_entities(&[(a, ChangeKind::Modified)]); + + let (_, a_end) = line_endpoints(&scene, a); + let (b_start, _) = line_endpoints(&scene, b); + assert!( + (a_end.x - b_start.x).abs() < 1e-6 && (a_end.y - b_start.y).abs() < 1e-6, + "a.end={a_end:?} b.start={b_start:?}" + ); +} + +#[test] +fn unrelated_entity_edit_does_not_touch_constrained_geometry() { + let mut scene = Scene::new(); + let line = add_line(&mut scene, 0.0, 0.0, 10.0, 3.0); // deliberately not horizontal + let unrelated = add_line(&mut scene, 100.0, 100.0, 200.0, 100.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![SketchRef::whole(line)], + None, + ); + + let (start_before, end_before) = line_endpoints(&scene, line); + set_line_end(&mut scene, unrelated, Vector3::new(200.0, 150.0, 0.0)); + scene.bump_entities(&[(unrelated, ChangeKind::Modified)]); + + let (start_after, end_after) = line_endpoints(&scene, line); + assert_eq!( + start_before, start_after, + "unrelated edit must not trigger a re-solve of unrelated geometry" + ); + assert_eq!(end_before, end_after); +} + +#[test] +fn erasing_a_constrained_entity_drops_its_constraints_instead_of_dangling() { + // Deleting an entity drops its constraints instead of leaving dangling refs. + let mut scene = Scene::new(); + let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + let b = add_line(&mut scene, 0.0, 5.0, 10.0, 5.0); + let c = add_line(&mut scene, 20.0, 20.0, 30.0, 20.0); // unrelated, own constraint + + let parallel_id = scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Parallel, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); + let horizontal_id = scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Horizontal, vec![SketchRef::whole(c)], None); + + scene.erase_entities(&[a]); + + let set = scene + .sketch_constraint_set(SketchScope::ModelSpace) + .expect("scope still exists"); + assert!( + set.get(parallel_id).is_none(), + "the Parallel constraint referencing the erased line must be gone" + ); + assert!( + set.get(horizontal_id).is_some(), + "an unrelated constraint on a different entity must survive" + ); + + // And the surviving constraint set still functions normally afterward. + set_line_end(&mut scene, c, Vector3::new(30.0, 25.0, 0.0)); + scene.bump_entities(&[(c, ChangeKind::Modified)]); + let (start, end) = line_endpoints(&scene, c); + assert!( + (start.y - end.y).abs() < 1e-6, + "remaining Horizontal constraint should still solve: start={start:?} end={end:?}" + ); +} + +#[test] +fn copying_two_constrained_entities_carries_their_constraint_along() { + // A constraint entirely between copied entities follows the copy. + let mut scene = Scene::new(); + let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + let b = add_line(&mut scene, 0.0, 5.0, 10.0, 3.0); // deliberately not parallel yet + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Parallel, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); + // Solve once so the source pair is actually parallel before copying. + scene.bump_entities(&[(a, ChangeKind::Modified), (b, ChangeKind::Modified)]); + let constraints_before = scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap() + .constraints + .len(); + + let transform = + OpenCADStudio::command::EntityTransform::Translate(glam::DVec3::new(100.0, 100.0, 0.0)); + let new_handles = scene.copy_entities(&[a, b], &transform); + assert_eq!(new_handles.len(), 2); + let (new_a, new_b) = (new_handles[0], new_handles[1]); + + let constraints_after = scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap() + .constraints + .len(); + assert_eq!( + constraints_after, + constraints_before + 1, + "the copy should have gained exactly one new Parallel constraint" + ); + + // Break the copy's parallelism, then confirm its own (copied) constraint + // re-solves it — proof this is a real, independent constraint on the + // copy, not just coincidentally-parallel geometry from the translate. + set_line_end(&mut scene, new_b, Vector3::new(110.0, 108.0, 0.0)); + scene.bump_entities(&[(new_b, ChangeKind::Modified)]); + let (a1, a2) = line_endpoints(&scene, new_a); + let (b1, b2) = line_endpoints(&scene, new_b); + let dir_a = (a2.x - a1.x, a2.y - a1.y); + let dir_b = (b2.x - b1.x, b2.y - b1.y); + let cross = dir_a.0 * dir_b.1 - dir_a.1 * dir_b.0; + assert!(cross.abs() < 1e-6, "the copied pair should still be held parallel by its own constraint: dir_a={dir_a:?} dir_b={dir_b:?}"); + + // The original pair's own constraint must be untouched (still its own + // record, independently referencing the original handles). + let (oa1, oa2) = line_endpoints(&scene, a); + let (ob1, ob2) = line_endpoints(&scene, b); + let odir_a = (oa2.x - oa1.x, oa2.y - oa1.y); + let odir_b = (ob2.x - ob1.x, ob2.y - ob1.y); + let ocross = odir_a.0 * odir_b.1 - odir_a.1 * odir_b.0; + assert!( + ocross.abs() < 1e-6, + "the original pair should remain parallel too" + ); +} + +// 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`. + +#[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; + + 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 + + scene.bump_entities(&[(a, ChangeKind::Modified)]); + + let set = scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap(); + assert_eq!( + set.conflicts.len(), + 1, + "exactly one of the two identical constraints should be flagged" + ); + let (flagged_id, kind) = set.conflicts[0]; + assert!( + flagged_id == first || flagged_id == second, + "the flagged id should be one of the two duplicates" + ); + assert_eq!(kind, RedundancyKind::Redundant); +} + +#[test] +fn two_conflicting_distance_targets_are_reported_as_conflicting() { + use cadkernel_constraints::diagnosis::RedundancyKind; + use OpenCADStudio::scene::sketch_constraints::ConstraintKind; + + let mut scene = Scene::new(); + let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let first = set.add( + ConstraintKind::Distance, + vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], + Some(DrivingValue::Literal(20.0)), + ); + let second = set.add( + ConstraintKind::Distance, + vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], + Some(DrivingValue::Literal(50.0)), + ); + + scene.bump_entities(&[(a, ChangeKind::Modified)]); + + let set = scene + .sketch_constraint_set(SketchScope::ModelSpace) + .unwrap(); + assert_eq!(set.conflicts.len(), 1); + let (flagged_id, kind) = set.conflicts[0]; + assert!( + flagged_id == first || flagged_id == second, + "the flagged id should be one of the two conflicting Distance constraints" + ); + assert_eq!(kind, RedundancyKind::Conflicting); +} + +fn set_circle_center(scene: &mut Scene, handle: Handle, center: Vector3) { + if let Some(EntityType::Circle(c)) = scene.document.get_entity_mut(handle) { + c.center = center; + } +} + +fn set_line_start(scene: &mut Scene, handle: Handle, start: Vector3) { + if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(handle) { + l.start = start; + } +} + +#[test] +fn concentric_constraint_pulls_the_second_circles_center_onto_the_firsts() { + let mut scene = Scene::new(); + let a = add_circle(&mut scene, 0.0, 0.0, 3.0); + let b = add_circle(&mut scene, 5.0, 5.0, 1.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Concentric, + vec![SketchRef::center(a), SketchRef::center(b)], + None, + ); + + set_circle_center(&mut scene, a, Vector3::new(2.0, -3.0, 0.0)); + scene.bump_entities(&[(a, ChangeKind::Modified)]); + + let (ca, ra) = circle_geom(&scene, a); + let (cb, rb) = circle_geom(&scene, b); + assert!( + (ca.x - cb.x).abs() < 1e-6 && (ca.y - cb.y).abs() < 1e-6, + "centers should coincide: a={ca:?} b={cb:?}" + ); + // Radii are untouched by Concentric — only centers move. + assert!( + (ra - 3.0).abs() < 1e-6 && (rb - 1.0).abs() < 1e-6, + "radii must not change: ra={ra} rb={rb}" + ); +} + +#[test] +fn center_point_constraint_pulls_a_lines_endpoint_onto_a_circles_center() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene, 0.0, 0.0, 4.0); + let line = add_line(&mut scene, 10.0, 10.0, 20.0, 20.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::CenterPoint, + vec![SketchRef::point(line, 0), SketchRef::center(circle)], + None, + ); + + set_circle_center(&mut scene, circle, Vector3::new(-6.0, 9.0, 0.0)); + scene.bump_entities(&[(circle, ChangeKind::Modified)]); + + let (center, _) = circle_geom(&scene, circle); + let (line_start, _) = line_endpoints(&scene, line); + assert!( + (center.x - line_start.x).abs() < 1e-6 && (center.y - line_start.y).abs() < 1e-6, + "the line's start should sit exactly at the circle's center: center={center:?} start={line_start:?}" + ); +} + +#[test] +fn colinear_constraint_pulls_the_second_line_onto_the_firsts_infinite_line() { + let mut scene = Scene::new(); + let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + let b = add_line(&mut scene, 3.0, 4.0, 7.0, 6.0); // off-axis, not on a's line + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Colinear, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); + + scene.bump_entities(&[(b, ChangeKind::Modified)]); + + let (a1, a2) = line_endpoints(&scene, a); + let (b1, b2) = line_endpoints(&scene, b); + // Both of b's endpoints should land on a's infinite line: the signed + // area of (a2-a1) × (bN-a1) is ~0 for a point exactly on that line. + let area = |p: Vector3| (a2.x - a1.x) * (p.y - a1.y) - (a2.y - a1.y) * (p.x - a1.x); + assert!( + area(b1).abs() < 1e-5, + "b.start should land on a's line, area={}", + area(b1) + ); + assert!( + area(b2).abs() < 1e-5, + "b.end should land on a's line, area={}", + area(b2) + ); +} + +#[test] +fn midpoint_constraint_pulls_a_point_onto_a_lines_midpoint() { + let mut scene = Scene::new(); + let base = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 21.0); // marker.start is the tracked point + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Midpoint, + vec![SketchRef::point(marker, 0), SketchRef::whole(base)], + None, + ); + + set_line_end(&mut scene, base, Vector3::new(10.0, 8.0, 0.0)); + scene.bump_entities(&[(base, ChangeKind::Modified)]); + + let (b1, b2) = line_endpoints(&scene, base); + let (marker_start, _) = line_endpoints(&scene, marker); + let expected = Vector3::new((b1.x + b2.x) / 2.0, (b1.y + b2.y) / 2.0, 0.0); + assert!( + (marker_start.x - expected.x).abs() < 1e-6 && (marker_start.y - expected.y).abs() < 1e-6, + "marker point should sit at base's midpoint: expected={expected:?} got={marker_start:?}" + ); +} + +#[test] +fn fixed_constraint_holds_an_entity_in_place_despite_a_connected_edit() { + let mut scene = Scene::new(); + let fixed_line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + let moving_line = add_line(&mut scene, 10.0, 0.0, 10.0, 10.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add( + ConstraintKind::Fixed, + vec![SketchRef::whole(fixed_line)], + None, + ); + set.add( + ConstraintKind::Coincident, + vec![ + SketchRef::point(fixed_line, 1), + SketchRef::point(moving_line, 0), + ], + None, + ); + + let (before_start, before_end) = line_endpoints(&scene, fixed_line); + + // Drag the shared point — without Fixed this would pull fixed_line's + // end along with it; Fixed should hold fixed_line exactly in place and + // let moving_line's start follow back to it instead. + set_line_start(&mut scene, moving_line, Vector3::new(15.0, 5.0, 0.0)); + scene.bump_entities(&[(moving_line, ChangeKind::Modified)]); + + let (after_start, after_end) = line_endpoints(&scene, fixed_line); + assert_eq!( + before_start, after_start, + "Fixed entity's start must not move" + ); + assert_eq!(before_end, after_end, "Fixed entity's end must not move"); + let (moving_start, _) = line_endpoints(&scene, moving_line); + assert!( + (moving_start.x - before_end.x).abs() < 1e-6 + && (moving_start.y - before_end.y).abs() < 1e-6, + "moving_line's start should have been pulled back to fixed_line's (unmoved) end" + ); +} + +#[test] +fn point_on_curve_constraint_pulls_a_point_onto_a_circles_circumference() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene, 0.0, 0.0, 5.0); + let marker = add_line(&mut scene, 100.0, 100.0, 101.0, 101.0); // marker.start is the tracked point + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::PointOnCurve, + vec![SketchRef::point(marker, 0), SketchRef::whole(circle)], + None, + ); + + scene.bump_entities(&[(marker, ChangeKind::Modified)]); + + let (center, radius) = circle_geom(&scene, circle); + let (marker_start, _) = line_endpoints(&scene, marker); + let dist = ((marker_start.x - center.x).powi(2) + (marker_start.y - center.y).powi(2)).sqrt(); + assert!( + (dist - radius).abs() < 1e-5, + "point should land on the circumference: dist={dist} radius={radius}" + ); +} + +#[test] +fn equal_distance_constraint_matches_a_second_point_pairs_separation() { + let mut scene = Scene::new(); + // Reference pair: fixed 6 units apart. + let a = add_line(&mut scene, 0.0, 0.0, 6.0, 0.0); + // Tracked pair: starts at some other separation, should be pulled to 6. + let b = add_line(&mut scene, 20.0, 20.0, 25.0, 20.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::EqualDistance, + vec![ + SketchRef::point(b, 0), + SketchRef::point(b, 1), + SketchRef::point(a, 0), + SketchRef::point(a, 1), + ], + None, + ); + + scene.bump_entities(&[(b, ChangeKind::Modified)]); + + let (a1, a2) = line_endpoints(&scene, a); + let (b1, b2) = line_endpoints(&scene, b); + let dist_a = ((a2.x - a1.x).powi(2) + (a2.y - a1.y).powi(2)).sqrt(); + let dist_b = ((b2.x - b1.x).powi(2) + (b2.y - b1.y).powi(2)).sqrt(); + assert!( + (dist_a - dist_b).abs() < 1e-6, + "the two pairs' separations should match: dist_a={dist_a} dist_b={dist_b}" + ); +} + +#[test] +fn symmetric_constraint_mirrors_one_circles_center_across_the_axis_line() { + let mut scene = Scene::new(); + let axis = add_line(&mut scene, 0.0, 0.0, 0.0, 10.0); // the Y axis + let a = add_circle(&mut scene, 3.0, 4.0, 1.0); + // Starts well off the mirrored position (and not coincident with `a` — + // a zero-length a/b segment would leave `Perpendicular`'s precomputed + // scale dividing by zero at the very first solve iteration). + let b = add_circle(&mut scene, 8.0, 9.0, 1.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + // Pin the axis and `a` in place — otherwise they're just as free to move + // as `b`'s center, and with only 2 equations (MidpointOnLine + + // Perpendicular) against that many more unknowns the solver is free to + // converge on any of infinitely many valid configurations, not + // necessarily "only b moves, to a's exact mirror" (which is the one + // deterministic outcome this test actually wants to check). + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(axis)], None); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(a)], None); + set.add( + ConstraintKind::Symmetric, + vec![ + SketchRef::center(a), + SketchRef::center(b), + SketchRef::whole(axis), + ], + None, + ); + + scene.bump_entities(&[(b, ChangeKind::Modified)]); + + let (ca, _) = circle_geom(&scene, a); + let (cb, _) = circle_geom(&scene, b); + assert_eq!( + ca, + Vector3::new(3.0, 4.0, 0.0), + "Fixed a's center must not have moved" + ); + // With both a and the axis pinned, b's center has a unique valid + // position left: a's exact mirror across the Y axis, (-3, 4). + assert!( + (cb.x - -3.0).abs() < 1e-5 && (cb.y - 4.0).abs() < 1e-5, + "b's center should have been pulled to a's mirror image across the axis: expected (-3, 4), got {cb:?}" + ); +} + +// Phase 1 of docs' constraint-parity plan: an Arc registers in the solver +// as its own center/radius (an `EntityGeom::Circle`), so every whole-circle +// constraint kind — Radius, Tangent, Concentric among them — already works +// on it. These mirror the equivalent Circle tests above one-for-one. + +#[test] +fn radius_constraint_on_an_arc_solves_to_the_target() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 3.0, 0.0, std::f64::consts::FRAC_PI_2); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Radius, + vec![SketchRef::whole(arc)], + Some(DrivingValue::Literal(7.5)), + ); + scene.bump_entities(&[(arc, ChangeKind::Modified)]); + + let (_, radius) = arc_geom(&scene, arc); + assert!( + (radius - 7.5).abs() < 1e-6, + "arc radius should have solved to the driven target: got {radius}" + ); +} + +#[test] +fn tangent_constraint_between_a_line_and_an_arc_solves_to_touching() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI); + let line = add_line(&mut scene, -10.0, 10.0, 10.0, 10.0); // clear of the arc's circle + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Tangent, + vec![SketchRef::whole(arc), SketchRef::whole(line)], + None, + ); + scene.bump_entities(&[(arc, ChangeKind::Modified), (line, ChangeKind::Modified)]); + + let (center, radius) = arc_geom(&scene, arc); + let (p1, p2) = line_endpoints(&scene, line); + let line_dir = (p2.x - p1.x, p2.y - p1.y); + let line_len = (line_dir.0 * line_dir.0 + line_dir.1 * line_dir.1).sqrt(); + let signed_dist = (line_dir.0 * (center.y - p1.y) - line_dir.1 * (center.x - p1.x)) / line_len; + assert!((signed_dist.abs() - radius).abs() < 1e-6, "center-to-line distance should equal the arc's radius after solving: dist={signed_dist} radius={radius}"); +} + +#[test] +fn concentric_constraint_between_an_arc_and_a_circle_pulls_centers_together() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 4.0, 0.0, std::f64::consts::PI); + let circle = add_circle(&mut scene, 10.0, -6.0, 2.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Concentric, + vec![SketchRef::center(arc), SketchRef::center(circle)], + None, + ); + scene.bump_entities(&[(arc, ChangeKind::Modified), (circle, ChangeKind::Modified)]); + + let (arc_center, _) = arc_geom(&scene, arc); + let (circle_center, _) = circle_geom(&scene, circle); + assert!( + (arc_center.x - circle_center.x).abs() < 1e-6 && (arc_center.y - circle_center.y).abs() < 1e-6, + "arc and circle centers should coincide after solving: arc={arc_center:?} circle={circle_center:?}" + ); +} + +// Phase 4b of the same plan: an arc's actual endpoints (marker 0/1, not +// just its center or whole curve) are now real, solvable points — kept +// consistent with center/radius/angle by the "arc rules" `CurveValue` +// constraints `solve_scope` adds for every registered arc. Coincident/ +// PointOnCurve/etc. touching an arc endpoint need no kind-specific code at +// all: they resolve through the same generic `point_ref`/`point_for_marker` +// path every other point-ref constraint already uses. + +fn arc_start_point(scene: &Scene, handle: Handle) -> Vector3 { + match scene.document.get_entity(handle).expect("entity exists") { + EntityType::Arc(a) => a.start_point(), + other => panic!("expected an Arc, got {other:?}"), + } +} + +#[test] +fn coincident_constraint_pulls_a_lines_endpoint_onto_an_arcs_start_point() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::FRAC_PI_2); + let line = add_line(&mut scene, 20.0, 20.0, 30.0, 30.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Coincident, + vec![SketchRef::point(arc, 0), SketchRef::point(line, 0)], + None, + ); + + // Move the arc directly (bypassing the solver), same pattern as the + // equivalent Circle/Ellipse CenterPoint tests — the constraint is a + // symmetric point-equality, so what's checked is that both sides end + // up equal to *each other*, not at some hardcoded absolute position. + if let Some(EntityType::Arc(a)) = scene.document.get_entity_mut(arc) { + a.center = Vector3::new(-8.0, 3.0, 0.0); + } + scene.bump_entities(&[(arc, ChangeKind::Modified)]); + + let arc_start = arc_start_point(&scene, arc); + let (line_start, _) = line_endpoints(&scene, line); + assert!( + (arc_start.x - line_start.x).abs() < 1e-6 && (arc_start.y - line_start.y).abs() < 1e-6, + "the line's start should sit exactly at the arc's start point: arc_start={arc_start:?} line_start={line_start:?}" + ); +} + +#[test] +fn an_arcs_endpoint_stays_consistent_with_its_center_radius_and_angle_after_solving() { + // The actual "arc rules" guarantee: after a solve that moved a + // constrained endpoint, recomputing the arc's start point from its + // (possibly also moved) center/radius/start_angle must land exactly on + // the same point the constraint pulled the endpoint to — not just "some + // point near it". This is what would drift if the `CurveValue` ties + // were missing or wrong. + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI); + let anchor = add_line(&mut scene, 12.0, 7.0, 12.0, 7.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(anchor)], None); + set.add( + ConstraintKind::Coincident, + vec![SketchRef::point(arc, 0), SketchRef::point(anchor, 0)], + None, + ); + scene.bump_entities(&[(arc, ChangeKind::Modified)]); + + let (center, radius, start_angle, _) = { + match scene.document.get_entity(arc).expect("entity exists") { + EntityType::Arc(a) => (a.center, a.radius, a.start_angle, a.end_angle), + other => panic!("expected an Arc, got {other:?}"), + } + }; + let recomputed_start = Vector3::new( + center.x + radius * start_angle.cos(), + center.y + radius * start_angle.sin(), + 0.0, + ); + let actual_start = arc_start_point(&scene, arc); + assert!( + (recomputed_start.x - actual_start.x).abs() < 1e-6 && (recomputed_start.y - actual_start.y).abs() < 1e-6, + "start_point() must match center+radius*angle exactly: recomputed={recomputed_start:?} actual={actual_start:?}" + ); + assert!( + (actual_start.x - 12.0).abs() < 1e-6 && (actual_start.y - 7.0).abs() < 1e-6, + "the constrained endpoint itself should have reached the fixed anchor: {actual_start:?}" + ); +} + +#[test] +fn point_on_curve_constraint_pulls_a_point_onto_an_arcs_underlying_circle() { + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI); + let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 20.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::PointOnCurve, + vec![SketchRef::point(marker, 0), SketchRef::whole(arc)], + None, + ); + scene.bump_entities(&[(marker, ChangeKind::Modified)]); + + let (center, radius) = arc_geom(&scene, arc); + let (p, _) = line_endpoints(&scene, marker); + let dist = ((p.x - center.x).powi(2) + (p.y - center.y).powi(2)).sqrt(); + assert!( + (dist - radius).abs() < 1e-6, + "point should land exactly on the arc's circle: dist={dist} radius={radius}" + ); +} + +#[test] +fn radius_and_endpoint_coincident_constraints_coexist_on_the_same_arc() { + // Exercises both registration paths for the same arc in one solve: the + // whole-circle path (`Radius`, via `.circle`) and the endpoint path + // (`Coincident` on marker 0, via the arc-rules-anchored `start` point). + let mut scene = Scene::new(); + let arc = add_arc(&mut scene, 0.0, 0.0, 3.0, 0.0, std::f64::consts::FRAC_PI_2); + let anchor = add_line(&mut scene, 9.0, -4.0, 9.0, -4.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(anchor)], None); + set.add( + ConstraintKind::Coincident, + vec![SketchRef::point(arc, 0), SketchRef::point(anchor, 0)], + None, + ); + set.add( + ConstraintKind::Radius, + vec![SketchRef::whole(arc)], + Some(DrivingValue::Literal(6.0)), + ); + scene.bump_entities(&[(arc, ChangeKind::Modified)]); + + let (_, radius) = arc_geom(&scene, arc); + assert!( + (radius - 6.0).abs() < 1e-6, + "Radius constraint should still hold: got {radius}" + ); + let actual_start = arc_start_point(&scene, arc); + assert!( + (actual_start.x - 9.0).abs() < 1e-6 && (actual_start.y - -4.0).abs() < 1e-6, + "the arc's start point should have reached the fixed anchor despite the simultaneous Radius constraint: {actual_start:?}" + ); +} + +// Phase 2 of the same plan: `Diameter`/`DistanceX`/`DistanceY` reuse +// existing `cadkernel_constraints` primitives (`Equal`'s `ratio`, `Difference`) with no +// new solver-crate code. + +#[test] +fn diameter_constraint_drives_the_radius_to_half_the_target() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene, 0.0, 0.0, 3.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Diameter, + vec![SketchRef::whole(circle)], + Some(DrivingValue::Literal(16.0)), + ); + scene.bump_entities(&[(circle, ChangeKind::Modified)]); + + let (_, radius) = circle_geom(&scene, circle); + assert!( + (radius - 8.0).abs() < 1e-6, + "radius should be half the diameter target: got {radius}" + ); +} + +#[test] +fn distance_x_constraint_pins_only_the_x_component() { + let mut scene = Scene::new(); + // Deliberately not axis-aligned so DistanceX and DistanceY are + // distinguishable from a plain Distance/Horizontal outcome. + let line = add_line(&mut scene, 0.0, 0.0, 6.0, 8.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::DistanceX, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Literal(10.0)), + ); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let (start, end) = line_endpoints(&scene, line); + assert!( + (end.x - start.x - 10.0).abs() < 1e-6, + "x component should equal the target: dx={}", + end.x - start.x + ); +} + +// Phase 3 of the same plan: `Normal` reduces to "line passes through the +// circle's center" for the Line/Circle-only entity model, reusing +// `PointOnLine` (the same primitive `PointOnCurve` already uses). + +#[test] +fn normal_constraint_pulls_the_line_through_the_circles_center() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene, 5.0, 5.0, 2.0); + // A horizontal line well clear of the circle's center. + let line = add_line(&mut scene, -10.0, 0.0, 10.0, 0.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Normal, + vec![SketchRef::whole(circle), SketchRef::whole(line)], + None, + ); + scene.bump_entities(&[(circle, ChangeKind::Modified), (line, ChangeKind::Modified)]); + + let (center, _) = circle_geom(&scene, circle); + let (p1, p2) = line_endpoints(&scene, line); + let line_dir = (p2.x - p1.x, p2.y - p1.y); + let line_len = (line_dir.0 * line_dir.0 + line_dir.1 * line_dir.1).sqrt(); + let signed_dist = (line_dir.0 * (center.y - p1.y) - line_dir.1 * (center.x - p1.x)) / line_len; + assert!( + signed_dist.abs() < 1e-6, + "the circle's center should lie on the (infinite) line after solving: dist={signed_dist}" + ); +} + +// Phase 4a of the same plan: `ArcLength` registers an arc's +// `start_angle`/`end_angle` separately from its center/radius (which still +// flow through the existing `EntityGeom::Circle` aliasing), so it can +// combine with a `Radius` constraint on the same arc in one solve. + +#[test] +fn arc_length_constraint_drives_the_swept_length_to_the_target() { + let mut scene = Scene::new(); + // radius 5, a 90° span (length ≈ 7.85) — deliberately not the target. + let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::FRAC_PI_2); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add( + ConstraintKind::Radius, + vec![SketchRef::whole(arc)], + Some(DrivingValue::Literal(5.0)), + ); + set.add( + ConstraintKind::ArcLength, + vec![SketchRef::whole(arc)], + Some(DrivingValue::Literal(10.0)), + ); + scene.bump_entities(&[(arc, ChangeKind::Modified)]); + + let (radius, start_angle, end_angle) = arc_angles(&scene, arc); + assert!( + (radius - 5.0).abs() < 1e-6, + "Radius constraint should still hold: got {radius}" + ); + let length = radius * (end_angle - start_angle); + assert!( + (length - 10.0).abs() < 1e-5, + "swept length should equal the ArcLength target: got {length}" + ); +} + +// Phase 5 of the same plan: an `Ellipse` registers center/focus1/minor- +// radius (converted from acadrust's center + major-axis-vector + ratio +// parametrization). Only center-based whole-entity constraints are wired +// up so far — Concentric, CenterPoint, Fixed — mirroring how Arc started +// at "center/radius only" in Phase 1. Axis-length constraints and +// `PointOnEllipse`-based Coincident/Tangent are a further follow-up. + +#[test] +fn concentric_constraint_between_an_ellipse_and_a_circle_pulls_centers_together() { + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (6.0, 0.0), 0.5); + let circle = add_circle(&mut scene, 10.0, -6.0, 2.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Concentric, + vec![SketchRef::center(ellipse), SketchRef::center(circle)], + None, + ); + scene.bump_entities(&[ + (ellipse, ChangeKind::Modified), + (circle, ChangeKind::Modified), + ]); + + let (ellipse_center, _, _) = ellipse_geom(&scene, ellipse); + let (circle_center, _) = circle_geom(&scene, circle); + assert!( + (ellipse_center.x - circle_center.x).abs() < 1e-6 && (ellipse_center.y - circle_center.y).abs() < 1e-6, + "ellipse and circle centers should coincide after solving: ellipse={ellipse_center:?} circle={circle_center:?}" + ); +} + +#[test] +fn center_point_constraint_pulls_a_lines_endpoint_onto_an_ellipses_center() { + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6); + let line = add_line(&mut scene, 10.0, 10.0, 20.0, 20.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::CenterPoint, + vec![SketchRef::point(line, 0), SketchRef::center(ellipse)], + None, + ); + + // Move the ellipse directly (bypassing the solver) before the first + // solve, same as the equivalent Circle test — the constraint is a + // symmetric point-equality, so what's checked is that both sides end + // up equal to *each other*, not at some hardcoded absolute position. + if let Some(EntityType::Ellipse(e)) = scene.document.get_entity_mut(ellipse) { + e.center = Vector3::new(-6.0, 9.0, 0.0); + } + scene.bump_entities(&[(ellipse, ChangeKind::Modified)]); + + let (center, _, _) = ellipse_geom(&scene, ellipse); + let (line_start, _) = line_endpoints(&scene, line); + assert!( + (center.x - line_start.x).abs() < 1e-6 && (center.y - line_start.y).abs() < 1e-6, + "the line's start should sit exactly at the ellipse's center: center={center:?} start={line_start:?}" + ); +} + +#[test] +fn a_tilted_ellipse_round_trips_through_the_solver_without_drifting() { + // The riskiest part of Ellipse support: converting acadrust's center + + // major-axis-*vector* + minor/major ratio into `cadkernel_constraints`'s center + + // focus + minor-radius, then back, for a major axis that ISN'T + // axis-aligned (an axis-aligned one wouldn't exercise the direction + // math at all). A solve with nothing actually pulling on it should + // reproduce the same ellipse, not drift. + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 2.0, -3.0, (4.0, 3.0), 0.6); // major radius 5, tilted + let circle = add_circle(&mut scene, 2.0, -3.0, 1.0); // already concentric — no solving needed + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Concentric, + vec![SketchRef::center(ellipse), SketchRef::center(circle)], + None, + ); + scene.bump_entities(&[(ellipse, ChangeKind::Modified)]); + + let (center, major_axis, ratio) = ellipse_geom(&scene, ellipse); + assert!( + (center.x - 2.0).abs() < 1e-6 && (center.y - -3.0).abs() < 1e-6, + "center should not have drifted: {center:?}" + ); + assert!( + (major_axis.x - 4.0).abs() < 1e-6 && (major_axis.y - 3.0).abs() < 1e-6, + "major axis should not have drifted: {major_axis:?}" + ); + assert!( + (ratio - 0.6).abs() < 1e-6, + "minor/major ratio should not have drifted: {ratio}" + ); +} + +#[test] +fn concentric_constraint_does_not_reshape_the_ellipse_when_its_center_actually_moves() { + // Regression test for a real solver bug: `a_tilted_ellipse_round_trips_ + // through_the_solver_without_drifting` (above) uses a circle that's + // ALREADY concentric with the ellipse, so Concentric's equations start + // out satisfied and the ellipse's center never actually has to move — + // it doesn't exercise the bug at all. Here the circle starts elsewhere, + // forcing a real center move, which is exactly when `cadkernel_constraints::geo:: + // Ellipse`'s absolute `focus1` point used to get left behind: `center` + // moved to meet the circle, `focus1` didn't (nothing else referenced + // it), so the derived major-axis direction/length and minor/major + // ratio changed even though only a center-to-center constraint was + // ever applied. The fix ties `focus1`'s offset from `center` to its + // seeded value, so it translates along with `center` instead. + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (4.0, 0.0), 0.5); + let circle = add_circle(&mut scene, 10.0, 7.0, 1.5); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Concentric, + vec![SketchRef::center(ellipse), SketchRef::center(circle)], + None, + ); + scene.bump_entities(&[ + (ellipse, ChangeKind::Modified), + (circle, ChangeKind::Modified), + ]); + + let (ellipse_center, major_axis, ratio) = ellipse_geom(&scene, ellipse); + let (circle_center, _) = circle_geom(&scene, circle); + + assert!( + (ellipse_center.x - circle_center.x).abs() < 1e-6 && (ellipse_center.y - circle_center.y).abs() < 1e-6, + "concentric constraint should still hold: ellipse={ellipse_center:?} circle={circle_center:?}" + ); + // The whole point: the center was free to move (nothing else pinned + // either entity), so it's not expected to still sit at (0,0) — but the + // ellipse's *shape* must be exactly what it was before solving. + assert!( + (major_axis.x - 4.0).abs() < 1e-6 && (major_axis.y - 0.0).abs() < 1e-6, + "major axis must not rotate or resize just because the center moved: {major_axis:?}" + ); + assert!( + (ratio - 0.5).abs() < 1e-6, + "minor/major ratio must not change just because the center moved: {ratio}" + ); +} + +#[test] +fn fixed_constraint_holds_an_ellipse_in_place_despite_a_connected_edit() { + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (6.0, 0.0), 0.5); + let line = add_line(&mut scene, 0.0, 0.0, 5.0, 5.0); + + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Fixed, vec![SketchRef::whole(ellipse)], None); + set.add( + ConstraintKind::CenterPoint, + vec![SketchRef::point(line, 0), SketchRef::center(ellipse)], + None, + ); + + let before = ellipse_geom(&scene, ellipse); + + // Drag the line's start away — without Fixed this would pull the + // ellipse's center along with it; Fixed should hold the ellipse + // exactly in place and let the line's start follow back to it instead. + set_line_start(&mut scene, line, Vector3::new(20.0, -8.0, 0.0)); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let after = ellipse_geom(&scene, ellipse); + assert_eq!( + before, after, + "Fixed ellipse must not move at all (center, major axis, or ratio)" + ); + let (start, _) = line_endpoints(&scene, line); + assert!( + (start.x - before.0.x).abs() < 1e-6 && (start.y - before.0.y).abs() < 1e-6, + "line's start should have been pulled back to the (unmoved) ellipse center" + ); +} + +#[test] +fn distance_y_constraint_pins_only_the_y_component() { + let mut scene = Scene::new(); + let line = add_line(&mut scene, 0.0, 0.0, 6.0, 8.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::DistanceY, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Literal(-3.0)), + ); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let (start, end) = line_endpoints(&scene, line); + assert!( + (end.y - start.y - -3.0).abs() < 1e-6, + "y component should equal the (signed) target: dy={}", + end.y - start.y + ); +} diff --git a/tests/sketch_constraints_xrecord_roundtrip.rs b/tests/sketch_constraints_xrecord_roundtrip.rs new file mode 100644 index 000000000..435218ac4 --- /dev/null +++ b/tests/sketch_constraints_xrecord_roundtrip.rs @@ -0,0 +1,159 @@ +// 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()); +}