diff --git a/Cargo.lock b/Cargo.lock index 9acbdfdf0..bf2583875 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", @@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=f23ffc873228e5f41b876485c741e4a8ac20a67d#f23ffc873228e5f41b876485c741e4a8ac20a67d" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=2f2cd25832db298524fb5eb36ced5a438a877e95#2f2cd25832db298524fb5eb36ced5a438a877e95" dependencies = [ "ahash 0.8.12", "anyhow", @@ -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" @@ -885,7 +894,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=0507b720fef47bd7da0d4f777f97f1743a4f4038#0507b720fef47bd7da0d4f777f97f1743a4f4038" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=0a24ed40d0872ca8b3ac54bf8c280ce7824ec6c5#0a24ed40d0872ca8b3ac54bf8c280ce7824ec6c5" dependencies = [ "acadrust", "cavalier_contours", @@ -893,6 +902,14 @@ dependencies = [ "spade", ] +[[package]] +name = "cadkernel-constraints" +version = "0.1.0" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=0a24ed40d0872ca8b3ac54bf8c280ce7824ec6c5#0a24ed40d0872ca8b3ac54bf8c280ce7824ec6c5" +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 0a526489a..df90de83a 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] @@ -31,8 +30,9 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0507b720fef47bd7da0d4f777f97f1743a4f4038", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "2f2cd25832db298524fb5eb36ced5a438a877e95", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0a24ed40d0872ca8b3ac54bf8c280ce7824ec6c5", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0a24ed40d0872ca8b3ac54bf8c280ce7824ec6c5" } 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 744d3559e..000000000 --- a/crates/ocs_doc_api/Cargo.toml +++ /dev/null @@ -1,66 +0,0 @@ -[package] -name = "ocs_doc_api" -version = "0.1.0" -edition = "2021" -description = "Object-oriented, transport-agnostic CAD entity API for OpenCADStudio plugins and hosts (v2)." -license = "MIT" -build = "build.rs" - -# Dependency wiring: pin the SAME cadkernel/acadrust git revs -# as the host so `Body`/`EntityType` types are ABI-identical. -[dependencies] -serde = { version = "1", features = ["derive"] } -bincode = "1.3" -thiserror = "1" - -# Host adapters convert ObjectId to acadrust handles; kernel handles stay local. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0507b720fef47bd7da0d4f777f97f1743a4f4038", features = ["acis", "offset"], optional = true } -ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } - -[build-dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" -toml = "1" - -[dev-dependencies] -serde_json = "1" -toml = "1" - -[features] -# Default keeps the facade and DTOs independent of geometry dependencies. -default = [] -# host: pull acadrust + cadkernel + the executor/backend trait (in-process side). -host = ["acadrust", "cadkernel"] -# ipc: pull ocs_plugin_api for the IPC transport adapter (plugin side). -ipc = ["acadrust", "ocs_plugin_api"] -# doc_api_host: convenience helper that builds a DocApi from a HostApi (IPC-only). -doc_api_host = ["ipc"] - -[lib] -name = "ocs_doc_api" -path = "src/lib.rs" - -[[test]] -name = "mock_backend" -required-features = ["host"] - -[[example]] -name = "e1_primitives_and_queries" -required-features = ["host"] - -[[example]] -name = "e2_intersect_two_solids" -required-features = ["host"] - -[[example]] -name = "e3_transform_and_chain" -required-features = ["host"] - -[[example]] -name = "e4_2d_entities_and_bulk" -required-features = ["host"] - -[[example]] -name = "e5_failure_and_guards" -required-features = ["host"] diff --git a/crates/ocs_doc_api/README.md b/crates/ocs_doc_api/README.md deleted file mode 100644 index cc38a56ca..000000000 --- a/crates/ocs_doc_api/README.md +++ /dev/null @@ -1,139 +0,0 @@ -# ocs_doc_api - -Typed document operations and queries for OpenCADStudio plugins and hosts. -The same facade runs over an in-process backend or the plugin IPC channel. - -```rust -use ocs_doc_api::{ApiResult, DocApi}; - -fn intersect_solids(api: &DocApi) -> ApiResult { - let doc = api.document(api.active_tab()); - let a = doc.solids().create_cuboid([0.0; 3], [10.0; 3])?; - let b = doc.solids().create_cuboid([5.0; 3], [10.0; 3])?; - a.intersect(&b)?.volume() -} -``` - -Each successful write marks the document changed and records one undo step. -A failed operation leaves the document unchanged. Bulk creation and transforms -prepare every entity before applying changes. Separate calls commit separately; -`OpGroup` offers best-effort cleanup of creations, not rollback. - -Boolean `intersect`, `union` and `subtract` replace the first solid and erase the -second. `intersects` only tests bounding-box overlap. Volume and centroid are -computed by the kernel from a tessellated solid (chord tolerance 0.1 drawing units, -angular tolerance 0.0001 radians), then cached per geometry revision. A request -may compute at most 32 uncached mass properties; excess work returns an error. -Bulk operations and query batches are capped at 100,000 items. - -Transports bind to one document tab. Typed handles from another transport and -requests for another tab are rejected. Raw `ObjectId` values are relative to the -receiving document. Errors are serialized as `ApiError` variants over IPC. - -Layer CRUD is exposed through `Document::layers()`, `create_layer(info)`, -`update_layer(name, info)`, `delete_layer(name)` and through the `layer()` / -`set_layer(...)` methods on every entity handle (`Line`, `Circle`, `Solid`, -`Entity`, etc.). Layer properties are carried as the plain-data `LayerInfo` DTO -with `LayerFlags`, `Color` and `LineWeight`. The host enforces CAD-style rules: -layer "0" and the current layer cannot be deleted, duplicate names are rejected, -and a layer still referenced by entities cannot be removed. - -XDATA and XRECORD payloads are carried as plain-data, serde-compatible DTOs -(`XDataRecord`, `XRecordSpec`, and their value/entry types) and roundtripped -verbatim by the host. Every `Entity` handle can read/write one XDATA record per -registered application name via `xdata(application_name)` and `set_xdata(...)`, -and standalone XRECORD objects can be created with `doc.entities().create_xrecord(...)` -and updated through the `XRecord` handle. - -| Feature | Use | -|---|---| -| Default | DTOs, facade, transport trait and binding schema | -| `host` | Kernel and entity adapters, executor, `DocApiBackend`, `InProcess` | -| `ipc` | `OcsPluginApiIpc` adapter for a plugin connected to the host | -| `doc_api_host` | Convenience `doc_api_for_host(&dyn HostApi) -> Option` for out-of-process / worker-thread plugins | - -### Connecting from a plugin - -For out-of-process or worker-thread plugins that receive `&dyn HostApi` (or any -`ocs_plugin_api::host::HostApi` implementor), enable the `doc_api_host` feature -and use the helper instead of manually serializing `DocApiEnvelope`s: - -```rust -use ocs_doc_api::doc_api_for_host; -use ocs_plugin_api::host::HostApi; - -fn on_dispatch(host: &mut dyn HostApi) -> Option<()> { - let doc_api = doc_api_for_host(host)?; - let doc = doc_api.document(host.tab_id()); - let cube = doc.solids().create_cuboid([0.0; 3], [10.0; 3]).ok()?; - Some(()) -} -``` - -`doc_api_for_host` returns `None` for hosts that do not expose a -`PluginRequestSender` (for example the in-process `HostSession`). In-process code -should use `DocApi::in_process(backend, tab_id)` with a concrete -`DocApiBackend`. - -### Plugin example: create, read, update and delete a line - -```rust -use ocs_doc_api::{doc_api_for_host, ApiResult, Color, LayerInfo, LineWeight}; -use ocs_plugin_api::host::HostApi; - -fn draw_and_modify_line(host: &mut dyn HostApi) -> ApiResult<()> { - // Build a DocApi from the host's plugin request channel. - let api = doc_api_for_host(host).ok_or_else(|| { - ocs_doc_api::ApiError::Transport("host does not expose a DocApi channel".into()) - })?; - let doc = api.document(api.active_tab()); - - // Ensure a target layer exists. - let mut info = LayerInfo::new("MyLayer"); - info.color = Color::Index(1); - info.line_weight = LineWeight::Value(25); - let _ = doc.create_layer(&info); // ok if it already exists - - // Create a line on that layer. - let line = doc.curves().create_line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])?; - line.set_layer("MyLayer")?; - - // Read it back. - assert_eq!(line.layer()?, "MYLAYER"); - - // Move the endpoint in place (same ObjectId). - line.transform(ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]))?; - - // Delete it; the document is left unchanged if this failed. - line.delete()?; - Ok(()) -} -``` - -### Plugin example: create a box and read its volume - -```rust -use ocs_doc_api::{doc_api_for_host, ApiResult}; -use ocs_plugin_api::host::HostApi; - -fn create_box_and_measure(host: &mut dyn HostApi) -> ApiResult { - let api = doc_api_for_host(host).ok_or_else(|| { - ocs_doc_api::ApiError::Transport("host does not expose a DocApi channel".into()) - })?; - let doc = api.document(api.active_tab()); - - let box_ = doc.solids().create_cuboid([0.0; 3], [10.0; 3])?; - let volume = box_.volume()?; - - // Boolean example: intersect with a second box. - let other = doc.solids().create_cuboid([5.0; 3], [10.0; 3])?; - let intersection = box_.intersect(&other)?; - let _intersection_volume = intersection.volume()?; - - Ok(volume) -} -``` - -See the [API reference](src/gen/api_reference.md) for constructors and methods, -[architecture](ARCHITECTURE.md) for backend rules, and -[binding guide](bindings/README.md) for the Python facade and transport contract. diff --git a/crates/ocs_doc_api/bindings/README.md b/crates/ocs_doc_api/bindings/README.md deleted file mode 100644 index deb13c55d..000000000 --- a/crates/ocs_doc_api/bindings/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Language bindings - -`src/gen/binding_schema.json` describes handle families, constructors, methods, -arguments and operation/query mappings. `build.rs` derives it from -`spec/entities.toml`. Its `layouts` section is manually maintained vocabulary; -it does not contain everything needed to generate a bincode codec. - -Generate and test the Python facade from the repository root: - -```sh -python3 crates/ocs_doc_api/bindings/python/generate.py > /tmp/ocs_doc.py -python3 -m unittest discover -s crates/ocs_doc_api/bindings/python -``` - -The generated `DocApi(transport, active_tab)` expects a caller-supplied -`transport.apply(envelope) -> Receipt`. It does not open a plugin connection. -An envelope has `version` and a Python tuple `("Op", operation_dict)` or -`("Queries", [query_dict])`. Enum payloads use externally tagged dictionaries, -for example `{"CreateSolid": {"Sphere": {"centre": [0, 0, 0], "radius": 2}}}`. -The bridge converts these objects to the Rust DTOs and bincode 1.3 wire format, -sends `DocApiRequest { tab_id, bytes }`, and decodes `Result`. -Host plugin API version 6 and document envelope version 1 are required. - -For Rust plugins compiled against `ocs_doc_api` with the `doc_api_host` feature, -use the `doc_api_for_host` helper to build a typed `DocApi` from any -`ocs_plugin_api::host::HostApi` that exposes a `PluginRequestSender` -(out-of-process / worker-thread hosts): - -```rust -use ocs_doc_api::doc_api_for_host; -use ocs_plugin_api::host::HostApi; - -fn on_dispatch(host: &mut dyn HostApi) -> Option<()> { - let doc_api = doc_api_for_host(host)?; - let doc = doc_api.document(host.tab_id()); - let cube = doc.solids().create_cuboid([0.0; 3], [10.0; 3]).ok()?; - // ... - Some(()) -} -``` - -In-process plugins should use `DocApi::in_process(backend, tab_id)` with a -concrete `DocApiBackend`; `doc_api_for_host` returns `None` for hosts that do not -expose a `PluginRequestSender`. - -Decode receipt outcomes as `{"NewId": 42}` or `{"NewIds": [42, 43]}` and query -results as externally tagged dictionaries such as `{"Volume": 12.5}`. The facade -unwraps these into typed handles or values. Layer results use `{"Layers": [...]}` -and `{"EntityLayer": "WALLS"}`. Layer properties (`LayerInfo`, `LayerFlags`, -`Color`, `LineWeight`) appear as plain dictionaries in operation payloads, e.g. -`{"CreateLayer": {"name": "Walls", "flags": {"frozen": false, "locked": false, -"frozen_in_new_viewport": false, "off": false}, "color": {"Index": 7}, -"line_type": "Continuous", "line_weight": {"Default": null}, -"plot_style": "", "is_plottable": true}}`. -XDATA and XRECORD payloads appear as nested externally tagged dictionaries -(e.g. `{"XData": {"application_name": ..., "values": [...]}}` or -`{"XRecord": {"name": ..., "entries": [...]}}`). The bridge must raise an -exception for an error response. Transport binding determines the tab; requesting -another tab or combining typed handles from different documents raises an error. diff --git a/crates/ocs_doc_api/bindings/python/generate.py b/crates/ocs_doc_api/bindings/python/generate.py deleted file mode 100644 index d361493ba..000000000 --- a/crates/ocs_doc_api/bindings/python/generate.py +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 -"""Generate a Python facade over a caller-supplied document transport. - -Usage: python generate.py ../../src/gen/binding_schema.json > ocs_doc.py -The transport converts these Python DTOs to/from the Rust wire representation. -""" -import json -import sys -from pathlib import Path - -HEADER = '''"""Generated document facade. See bindings/README.md for the transport contract.""" -from dataclasses import dataclass -from typing import Any - -@dataclass -class DocApiEnvelope: - version: int - body: Any - -@dataclass -class Receipt: - outcome: Any - query_results: list - new_revision: int - -@dataclass -class Entity: - id: int - _doc: "Document" - - def bounds(self): - return self._doc._apply_query({"GetBounds": {"id": self.id}}) - - def transform(self, placement): - self._doc._apply_op({"Transform": {"id": self.id, "placement": placement}}) - - def delete(self): - self._doc._apply_op({"Delete": {"id": self.id}}) - - def layer(self): - return self._doc._apply_query({"GetEntityLayer": {"id": self.id}}) - - def set_layer(self, layer): - self._doc._apply_op({"SetEntityLayer": {"id": self.id, "layer": layer}}) -''' - -DOCUMENT = ''' -@dataclass -class Document: - _transport: Any - _tab: int - - def _id(self, value): - if isinstance(value, Entity): - if value._doc._transport is not self._transport or value._doc._tab != self._tab: - raise ValueError("handle belongs to another document") - return value.id - return int(value) - - def _apply_op(self, op, want=None): - receipt = self._transport.apply(DocApiEnvelope(ENVELOPE_VERSION, ("Op", op))) - if want is None: - return None - if want.startswith("Vec<"): - handle = globals()[want[4:-1]] - return [handle(i, self) for i in receipt.outcome["NewIds"]] - return globals()[want](receipt.outcome["NewId"], self) - - def _apply_query(self, query): - receipt = self._transport.apply(DocApiEnvelope(ENVELOPE_VERSION, ("Queries", [query]))) - if len(receipt.query_results) != 1: - raise ValueError("expected one query result") - result = receipt.query_results[0] - if not isinstance(result, dict) or len(result) != 1: - raise ValueError("invalid query result") - return next(iter(result.values())) - - def revision(self): - return self._apply_query("GetGeometryRevision") - - def block_entities(self, block_name): - return self._apply_query({"GetBlockEntities": {"block_name": block_name}}) - - def solids(self): return Solids(self) - def curves(self): return Curves(self) - def entities(self): return Entities(self) - - def layers(self): - return self._apply_query({"ListLayers": {}}) - - def create_layer(self, info): - self._apply_op({"CreateLayer": info}) - - def update_layer(self, name, info): - self._apply_op({"UpdateLayer": {"name": name, "info": info}}) - - def delete_layer(self, name): - self._apply_op({"DeleteLayer": {"name": name}}) - -@dataclass -class DocApi: - _transport: Any - active_tab: int = 0 - - def document(self, tab): - if tab != self.active_tab: - raise ValueError("transport is bound to another tab") - return Document(self._transport, tab) -''' - - -def render_method(method, constructor=False): - args = method.get("args", []) - signature = "".join(", " + a["name"] for a in args) - variant = method.get("op") or method["query"] - fields = {} - for arg in args: - name, ty = arg["name"], arg["ty"] - if ty in ("EntityRef", "Solid", "ObjectId"): - fields[name] = f"self._doc._id({name})" - elif ty == "&[EntityRef]": - fields[name] = f"[self._doc._id(p) for p in {name}]" - else: - fields[name] = name - fixed = dict(method.get("fixed") or {}) - if variant in ("SolidBoolean", "GetIntersects"): - fields = {"a": "self.id", "b": fields.pop("other"), **fields} - fields.pop("other", None) - elif not constructor: - fields = {"id": "self.id", **fields} - if variant == "Revolve": - fields["axis"] = "(pivot, axis)" - fields.pop("pivot") - nested = fixed.pop("primitive", fixed.pop("curve", None)) - fields.update({key: repr(value) for key, value in fixed.items()}) - payload = "{" + ", ".join(repr(k) + ": " + v for k, v in fields.items()) + "}" - if variant == "CreateMany": - payload = "[{'Curve': {'Point': {'position': p}}} for p in positions]" - elif nested: - payload = "{" + repr(nested) + ": " + payload + "}" - message = "{" + repr(variant) + ": " + payload + "}" - if method.get("kind") == "query": - call = f"self._doc._apply_query({message})" - else: - want = method.get("returns") - if want == "()": - want = None - call = f"self._doc._apply_op({message}, {want!r})" - return f" def {method['name']}(self{signature}):\n return {call}\n" - - -def render(schema): - out = [HEADER, f"ENVELOPE_VERSION = {schema['envelope_version']}\n"] - handles = {name: {} for name in schema["object_model"]["handles"]} - collections = {"solids": {}, "curves": {}, "entities": {}} - for family in schema["families"]: - handle = family.get("handle") or family["name"] - for method in family.get("methods", []): - existing = handles[handle].get(method["name"]) - if existing is not None and existing != method: - raise ValueError(f"conflicting method: {handle}.{method['name']}") - handles[handle][method["name"]] = method - for constructor in family.get("constructors", []): - collections[family["collection"]][constructor["name"]] = constructor - # Families sharing Entity contribute to one class, preserving all methods. - entity_methods = "\n".join(render_method(m) for m in handles.pop("Entity").values()) - out[0] += entity_methods - for handle, methods in handles.items(): - out.append(f"\nclass {handle}(Entity):\n" + ( - "\n".join(render_method(m) for m in methods.values()) or " pass\n")) - for collection, constructors in collections.items(): - out.append(f"\nclass {collection.title()}:\n def __init__(self, doc):\n self._doc = doc\n") - out.extend(render_method(c, constructor=True) for c in constructors.values()) - out.append(DOCUMENT) - return "\n".join(out) - - -def main(): - default = Path(__file__).resolve().parents[2] / "src/gen/binding_schema.json" - path = Path(sys.argv[1]) if len(sys.argv) > 1 else default - sys.stdout.write(render(json.loads(path.read_text(encoding="utf-8")))) - - -if __name__ == "__main__": - main() diff --git a/crates/ocs_doc_api/bindings/python/test_generate.py b/crates/ocs_doc_api/bindings/python/test_generate.py deleted file mode 100644 index b63ed1fe8..000000000 --- a/crates/ocs_doc_api/bindings/python/test_generate.py +++ /dev/null @@ -1,75 +0,0 @@ -import inspect -import json -from pathlib import Path -import types -import sys -import unittest - -from generate import render - - -class BindingTest(unittest.TestCase): - def setUp(self): - self.schema = json.loads((Path(__file__).resolve().parents[2] / 'src/gen/binding_schema.json').read_text()) - self.module = types.ModuleType('generated') - sys.modules['generated'] = self.module - self.addCleanup(sys.modules.pop, 'generated') - exec(render(self.schema), self.module.__dict__) - module = self.module - - class Transport: - def __init__(self): - self.calls = [] - - def apply(self, envelope): - self.calls.append(envelope) - kind, payload = envelope.body - if kind == 'Queries': - return module.Receipt(None, [{'Volume': 125.0}], 2) - if 'CreateMany' in payload: - return module.Receipt({'NewIds': [10, 11]}, [], 2) - return module.Receipt({'NewId': 9}, [], 2) - - self.transport = Transport() - self.api = module.DocApi(self.transport, active_tab=4) - self.doc = self.api.document(4) - - def test_constructors_returns_and_wire_payloads(self): - solid = self.doc.solids().create_cuboid([0, 0, 0], [5, 5, 5]) - self.assertIsInstance(solid, self.module.Solid) - self.assertEqual(self.transport.calls[-1].body, ('Op', {'CreateSolid': {'Cuboid': {'origin': [0, 0, 0], 'size': [5, 5, 5]}}})) - self.assertEqual(solid.volume(), 125.0) - self.assertIsInstance(solid.intersect(solid), self.module.Solid) - self.assertEqual(self.transport.calls[-1].body[1], {'SolidBoolean': {'a': 9, 'b': 9, 'op': 'Intersection', 'erase_sources': True}}) - points = self.doc.curves().create_points([[0, 0, 0], [1, 1, 1]]) - self.assertEqual([p.id for p in points], [10, 11]) - self.assertTrue(all(isinstance(p, self.module.Point) for p in points)) - self.doc.solids().revolve(points[0], [0, 0, 0], [0, 0, 1], 3.14) - self.assertEqual(self.transport.calls[-1].body[1]['Revolve']['axis'], ([0, 0, 0], [0, 0, 1])) - self.doc.solids().loft(points) - self.assertEqual(self.transport.calls[-1].body[1], {'Loft': {'profiles': [10, 11]}}) - self.assertIsNone(points[0].delete()) - - def test_every_schema_method_and_constructor_survives_generation(self): - for family in self.schema['families']: - handle = getattr(self.module, family['handle']) - collection = getattr(self.doc, family['collection'])() - for method in family['methods']: - self.assertTrue(callable(getattr(handle, method['name']))) - for constructor in family['constructors']: - method = getattr(collection, constructor['name']) - self.assertEqual(list(inspect.signature(method).parameters), [a['name'] for a in constructor['args']]) - - def test_foreign_handles_and_tab_mismatch_do_not_send(self): - with self.assertRaises(ValueError): - self.api.document(5) - foreign = self.module.Solid(9, self.module.DocApi(object(), 4).document(4)) - local = self.module.Solid(9, self.doc) - for operation in [lambda: local.union(foreign), lambda: self.doc.solids().extrude(foreign, [0, 0, 1]), lambda: self.doc.solids().loft([foreign])]: - with self.assertRaises(ValueError): - operation() - self.assertEqual(self.transport.calls, []) - - -if __name__ == '__main__': - unittest.main() diff --git a/crates/ocs_doc_api/build.rs b/crates/ocs_doc_api/build.rs deleted file mode 100644 index c951f0d66..000000000 --- a/crates/ocs_doc_api/build.rs +++ /dev/null @@ -1,452 +0,0 @@ -//! Regenerate the API reference and binding schema from the curated spec. - -use std::env; -use std::fs; -use std::path::PathBuf; - -use serde::Deserialize; - -#[derive(Deserialize)] -struct Spec { - meta: Meta, - #[serde(default)] - family: Vec, - #[serde(default)] - kernel_map: Vec, -} - -#[derive(Deserialize)] -struct Meta { - envelope_version: u16, -} - -#[derive(Deserialize)] -struct Family { - name: String, - #[serde(default)] - acadrust_variant: Option, - #[serde(default)] - handle: Option, - #[serde(default)] - collection: Option, - #[serde(default)] - description: Option, - #[serde(default)] - constructor: Vec, - #[serde(default)] - method: Vec, -} - -#[derive(Deserialize)] -// `op`/`fixed` are part of the spec schema (used by binding_schema consumers and -// future codegen) even though api_reference generation doesn't read them yet. -#[allow(dead_code)] -struct Constructor { - name: String, - #[serde(default)] - args: Vec, - #[serde(default)] - op: Option, - #[serde(default)] - fixed: Option, - #[serde(default)] - bulk: bool, - #[serde(default)] - returns: Option, - #[serde(default)] - status: Option, -} - -#[derive(Deserialize)] -struct Method { - name: String, - kind: String, // "op" | "query" - #[serde(default)] - op: Option, - #[serde(default)] - query: Option, - #[serde(default)] - args: Vec, - #[serde(default)] - returns: Option, - #[serde(default)] - fixed: Option, - #[serde(default)] - status: Option, -} - -#[derive(Deserialize)] -struct Arg { - name: String, - ty: String, -} - -#[derive(Deserialize)] -struct KernelMap { - op: String, - kernel: String, - returns: String, -} - -fn main() { - let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); - let spec_path = manifest_dir.join("spec/entities.toml"); - println!("cargo:rerun-if-changed={}", spec_path.display()); - - println!("cargo:rerun-if-changed=src/gen/layouts.json"); - let spec_src = fs::read_to_string(&spec_path).expect("read spec/entities.toml"); - let spec: Spec = toml::from_str(&spec_src).expect("parse spec/entities.toml"); - - let gen_dir = manifest_dir.join("src/gen"); - fs::create_dir_all(&gen_dir).unwrap(); - - write_if_changed(&gen_dir.join("api_reference.md"), &api_reference_md(&spec)); - - write_if_changed( - &gen_dir.join("binding_schema.json"), - &binding_schema_json(&spec), - ); -} - -/// Rewrite a generated file only when content changed (keeps mtime/CI stable). -fn write_if_changed(path: &PathBuf, content: &str) { - let unchanged = fs::read_to_string(path) - .map(|old| old == content) - .unwrap_or(false); - if !unchanged { - fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display())); - } -} - -/// Render a method/constructor's `fixed` fields compactly (e.g. -/// `, op="Intersection", erase_sources=true`). Empty when no fixed fields. -fn fmt_fixed(fixed: Option<&toml::Value>) -> String { - let Some(fixed) = fixed else { - return String::new(); - }; - let Some(tbl) = fixed.as_table() else { - return String::new(); - }; - if tbl.is_empty() { - return String::new(); - } - let parts: Vec = tbl.iter().map(|(k, v)| format!("{k}={v}")).collect(); - format!("; fixed {}", parts.join(", ")) -} - -fn fmt_args(args: &[Arg]) -> String { - // Renders `(...)` with the args, or `()` for a zero-arg method. - if args.is_empty() { - return String::new(); - } - args.iter() - .map(|a| format!("{}: {}", a.name, a.ty)) - .collect::>() - .join(", ") -} - -/// The parenthesised argument list: `(a: T, b: U)` or `()` for zero args. -fn fmt_call_args(args: &[Arg]) -> String { - if args.is_empty() { - return "()".to_string(); - } - format!("({})", fmt_args(args)) -} - -/// The generic handle methods + cross-cutting surface (applies to every handle; -/// documented once here rather than per-family). -fn generic_reference_md() -> String { - r#"## Generic methods (every handle) - -Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, -`Ellipse`, `Spline`, `Ray`, `XLine`, `Text`, `MText`, `Dimension`, `Entity`) is a -`Clone`, `Send`, `Sync` token `{ ObjectId, session }` and supports: - -- **`id()` -> `ObjectId`** — the stable entity identity. -- **`bounds()` -> `Aabb`** — coarse bounding box (solids lift-on-miss; 2D entities - computed; later families may be `Unsupported`). -- **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1: - solids only.** -- **`delete()`** — remove the entity (one undo step). -- **`layer()` -> `String`** — the name of the layer the entity is on. -- **`set_layer(layer)`** — move the entity to an existing layer (one undo step; - blocked if the entity or target layer is locked). - -`Entity` additionally has `view()` (id + kind + bounds), `as_solid()` (typed -downcast when `kind == "Solid3D"`), and XDATA access through -`xdata(application_name)` / `set_xdata(application_name, record)`. - -## Layer table - -Layers are a named document table, not an entity family. `Document::layers()` -lists all layers as `Vec`. Table-level ops are `CreateLayer(info)`, -`UpdateLayer { name, info }` and `DeleteLayer { name }`. `Entity::layer()` -returns the layer name and `Entity::set_layer(layer)` moves the entity to an -existing layer. The host rejects duplicate names, deletion of layer "0" or the -current layer, and removing a layer that still has entities assigned. - -## Transports - -- **`InProcess`** (feature `host`) — drives a `DocApiBackend` directly in the host. -- **`OcsPluginApiIpc`** (feature `ipc`) — serializes envelopes over the - `ocs_plugin_api` `PluginRequestSender`/`PluginRequest` channel. The host routes - `DocApiRequest { tab_id, bytes }` to the same executor. -- **`doc_api_for_host(host)`** (feature `doc_api_host`) — convenience helper that - builds a `DocApi` from any `ocs_plugin_api::host::HostApi` exposing a - `PluginRequestSender` (out-of-process / worker-thread plugins). - -## Collections & cross-cutting - -- **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`. -- **`Document`** — `solids()` / `curves()` / `entities()` factories + lookup, - `revision()`, `assert_revision(rev)` (read-guard), `query_batch(|q| …)` (read-only - batch, one round-trip, **no revision bump**). -- **`EntityCollection`** — `get(id) -> Entity`, `delete(id)`, - `transform_many(&ids, placement)`, `delete_many(&ids)`. -- **`OpGroup`** — best-effort client-side failure cleanup (`track` / `commit` / - `compensate`). **Not** a transaction: no atomicity, no rollback. - -## Bulk ops — single op, all-or-nothing, item-capped - -`CreateMany` / `TransformMany` / `DeleteMany` are **single** write ops: one undo -step, one publish, and **all-or-nothing via upfront validation** (no rollback). -Item-capped at `BULK_ITEM_CAP = 100_000` to bound host execution. Use them for -bulk data (N ≳ a few hundred); per-op autocommit is the right tool for -modeling-scale work. - -## Semantics - -- **Per-op autocommit, no write transactions.** Every write op is ONE atomic host - call = one undo step. Multi-op write transactions were removed (security/ - integrity review). -- **Per-op atomicity.** Validate + compute geometry **before** any mutation; a - failed op applies nothing and records no undo step. `erase_sources` booleans run - only on success. -- **Structured errors.** `ApiError::{Validation, Geometry, UnknownId, Unsupported, - Transport}`; bulk validation names the failing index; a stale handle returns - `UnknownId` (never a panic). - -"# - .to_string() -} - -fn status_suffix(status: Option<&str>) -> &'static str { - match status { - Some("unsupported") => " — **not yet supported** (returns `ApiError::Unsupported`)", - Some("planned") => " — **planned** (no callable surface yet)", - _ => "", - } -} - -fn api_reference_md(spec: &Spec) -> String { - let mut s = String::new(); - s.push_str("# ocs_doc_api — API reference (auto-generated)\n\n"); - s.push_str(&format!( - "Envelope version: `{}`\n\n", - spec.meta.envelope_version - )); - s.push_str("Do NOT edit: regenerated by `build.rs` from `spec/entities.toml`.\n\n"); - - for fam in &spec.family { - let collection = fam.collection.as_deref().unwrap_or("-"); - let handle = fam.handle.as_deref().unwrap_or(&fam.name); - s.push_str(&format!( - "## `{}` (acadrust `{}`, collection `{}`)\n\n", - handle, - fam.acadrust_variant.as_deref().unwrap_or("-"), - collection - )); - if let Some(d) = &fam.description { - s.push_str(&format!("{d}\n\n")); - } - - if !fam.constructor.is_empty() { - s.push_str(&format!("### Constructors (`doc.{collection}()`)\n\n")); - for c in &fam.constructor { - let ret = c.returns.as_deref().unwrap_or(handle); - let bulk = if c.bulk { " *(bulk op)*" } else { "" }; - let op = c.op.as_deref().unwrap_or("-"); - let fixed = fmt_fixed(c.fixed.as_ref()); - s.push_str(&format!( - "- **{}**{} -> `{ret}`{bulk} — `{op}`{fixed}{}\n", - c.name, - fmt_call_args(&c.args), - status_suffix(c.status.as_deref()) - )); - } - s.push('\n'); - } - - if !fam.method.is_empty() { - s.push_str("### Methods\n\n"); - for m in &fam.method { - let target = m.op.as_deref().or(m.query.as_deref()).unwrap_or("-"); - let fixed = fmt_fixed(m.fixed.as_ref()); - s.push_str(&format!( - "- **{}**{} -> `{}` — {} `{}`{fixed}{}\n", - m.name, - fmt_call_args(&m.args), - m.returns.as_deref().unwrap_or("()"), - m.kind, - target, - status_suffix(m.status.as_deref()) - )); - } - s.push('\n'); - } - } - - s.push_str(&generic_reference_md()); - - s.push_str("## Kernel mapping\n\n| op | kernel call | returns |\n|---|---|---|\n"); - for k in &spec.kernel_map { - s.push_str(&format!("| {} | `{}` | {} |\n", k.op, k.kernel, k.returns)); - } - s.push('\n'); - s.push_str(&wire_vocabulary_md()); - s.push('\n'); - s.push_str(&error_and_envelope_md()); - s -} - -/// The wire vocabulary section: every `Operation`/`Query` variant with its payload, -/// read from `src/gen/layouts.json` (the curated wire-layout snapshot). -fn wire_vocabulary_md() -> String { - let layouts = read_layouts(); - let mut s = String::from("## Wire vocabulary\n\nEvery `Operation` is ONE atomic write op (one undo step); every `Query` is read-only (safe to batch). Variants are append-only (bincode discriminant stability). Requests ride `DocApiEnvelope`: `Op(Operation)` for a write, `Queries(Vec)` for a read batch.\n\n"); - s.push_str("### `Operation`\n\n"); - if let Some(variants) = layouts - .get("Operation") - .and_then(|o| o.get("enum")) - .and_then(|v| v.as_array()) - { - for v in variants { - if let Some(name) = v.as_str() { - s.push_str(&format!("- `{name}`\n")); - } - } - } - s.push_str("\n### `Query`\n\n"); - if let Some(variants) = layouts - .get("Query") - .and_then(|o| o.get("enum")) - .and_then(|v| v.as_array()) - { - for v in variants { - if let Some(name) = v.as_str() { - s.push_str(&format!("- `{name}`\n")); - } - } - } - s -} - -/// The error model + envelope/transport section. -fn error_and_envelope_md() -> String { - r#"## Errors (`ApiError`) - -| Variant | Meaning | -|---|---| -| `Validation { op, reason }` | Input rejected before any document mutation. | -| `Geometry { kind, msg }` | The kernel failed (boolean refused, invalid input, ACIS lift/lower). | -| `UnknownId(ObjectId)` | The handle is stale / deleted / never existed (never a panic). | -| `Unsupported(String)` | Capability not supported for this entity or backend. | -| `Transport(String)` | The channel failed (disconnected / oversized / timeout). | - -`ApiError` is serializable, so IPC returns the same structured error the in-process -executor produced. - -## Envelope & transport - -`DocApiEnvelope { version, body }` — `body` is `Op(Operation)` (a single write) or -`Queries(Vec)` (a read batch). bincode-serialized; `version` is the crate's -envelope version (bridges must check it). Transports: `InProcess` (host) and -`OcsPluginApiIpc` (plugin over `ocs_plugin_api`), both behind the `Transport: -Send + Sync` trait (`apply(envelope) -> Receipt`, `alive()`). `Receipt` carries -`outcome` (per-op result), `query_results`, and `new_revision`. -"# - .to_string() -} - -/// The merged, self-contained binding handover schema (§10.2). Object model + -/// method signatures + op/query mapping + curated wire vocabulary inlined per node. -/// The handle types for the binding schema, derived from the spec's families -/// ("Entity" always first, then the deduped family handles in spec order). -fn handles_for_schema(spec: &Spec) -> Vec { - let mut out = vec!["Entity".to_string()]; - for fam in &spec.family { - let h = fam.handle.as_deref().unwrap_or(&fam.name); - if h != "Entity" && !out.iter().any(|x| x == h) { - out.push(h.to_string()); - } - } - out -} - -fn binding_schema_json(spec: &Spec) -> String { - let registry = read_layouts(); - - let families: Vec = spec - .family - .iter() - .map(|fam| { - let methods: Vec = fam - .method - .iter() - .map(|m| { - serde_json::json!({ - "name": m.name, - "kind": m.kind, - "op": m.op, - "query": m.query, - "args": m.args.iter().map(|a| serde_json::json!({"name": a.name, "ty": a.ty})).collect::>(), - "returns": m.returns, - "bulk": m.op.as_deref() == Some("CreateMany"), - "read_only": m.kind == "query", - "fixed": m.fixed, - }) - }) - .collect(); - serde_json::json!({ - "name": fam.name, - "handle": fam.handle, - "collection": fam.collection, - "acadrust_variant": fam.acadrust_variant, - "methods": methods, - "constructors": fam.constructor.iter().map(|c| serde_json::json!({ - "name": c.name, "op": c.op, "fixed": c.fixed, "bulk": c.bulk, - "returns": c.returns.as_deref().unwrap_or(fam.handle.as_deref().unwrap_or(&fam.name)), - "args": c.args.iter().map(|a| serde_json::json!({"name": a.name, "ty": a.ty})).collect::>() - })).collect::>(), - }) - }) - .collect(); - - let schema = serde_json::json!({ - "schema": "ocs_doc_api/binding_schema", - "envelope_version": spec.meta.envelope_version, - "object_model": { - "root": "DocApi", - "document": "Document", - "collections": ["solids", "curves", "entities"], - // Derived from the spec's family handles (deduped, "Entity" first) so - // new handle families appear in the handover automatically. - "handles": handles_for_schema(spec), - }, - "families": families, - // Curated wire vocabulary (not a complete binary codec schema). - "layouts": registry, - }); - - serde_json::to_string_pretty(&schema).expect("serialize binding schema") -} - -/// Read the manually maintained wire vocabulary; malformed snapshots fail the build. -fn read_layouts() -> serde_json::Value { - let src = fs::read_to_string(layouts_snapshot_path()).expect("read layouts.json"); - serde_json::from_str(&src).expect("parse layouts.json") -} - -fn layouts_snapshot_path() -> PathBuf { - PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("src/gen/layouts.json") -} diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs deleted file mode 100644 index 8b7b7454b..000000000 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ /dev/null @@ -1,710 +0,0 @@ -//! Shared in-memory `DocApiBackend` for the runnable examples. -//! -//! Lets the examples run without the host binary: an in-process mock document -//! (HashMap + a kind table) behind the `InProcess` transport. -//! Not for production use — the real backend lives in the host (`src/app/doc_api.rs`). - -use std::collections::HashMap; - -use ocs_doc_api::backend::{DocApiBackend, KernelBody}; -use ocs_doc_api::{ - normalize_layer_name, Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, - GeometryRevision, LayerInfo, ObjectId, PlacementSpec, -}; - - -#[derive(Clone)] -pub struct MockBackend { - next_id: u64, - revision: u64, - undo_steps: u32, - bodies: HashMap, - kinds: HashMap, - /// Stored 2D curve specs so `bounds()` works on non-solid entities too. - curves: HashMap, - /// Stored text content for Text/MText annotations. - text_values: HashMap, - /// Stored hatch boundary loops. - hatch_boundaries: HashMap>>, - /// Stored dimension measurements. - dimension_measurements: HashMap, - /// Stored insert attributes. - attributes_store: HashMap>, - /// Stored viewport views (target, height). - viewport_views: HashMap, - /// Stored XDATA records keyed by (entity id, application name). - xdata_store: HashMap<(ObjectId, String), ocs_doc_api::XDataRecord>, - /// Stored XRECORD payloads keyed by entity id. - xrecord_store: HashMap, - /// Stored layer table. - layers: HashMap, - /// Layer name assigned to each entity. - entity_layers: HashMap, -} - -impl Default for MockBackend { - fn default() -> Self { - let mut backend = Self { - next_id: 0, - revision: 0, - undo_steps: 0, - bodies: HashMap::new(), - kinds: HashMap::new(), - curves: HashMap::new(), - text_values: HashMap::new(), - hatch_boundaries: HashMap::new(), - dimension_measurements: HashMap::new(), - attributes_store: HashMap::new(), - viewport_views: HashMap::new(), - xdata_store: HashMap::new(), - xrecord_store: HashMap::new(), - layers: HashMap::new(), - entity_layers: HashMap::new(), - }; - backend.layers.insert("0".to_string(), LayerInfo::new("0")); - backend - } -} - -impl MockBackend { - fn alloc(&mut self, kind: &str) -> ObjectId { - self.next_id += 1; - let id = ObjectId::from_u64(self.next_id); - self.kinds.insert(id, kind.to_string()); - id - } - fn body(&self, id: ObjectId) -> ApiResult<&KernelBody> { - self.bodies.get(&id).ok_or(ApiError::UnknownId(id)) - } - #[allow(dead_code)] - pub fn undo_steps(&self) -> u32 { - self.undo_steps - } -} - -/// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds). -fn curve_bounds(spec: &Curve2Spec) -> Aabb { - match spec { - Curve2Spec::Line { start, end, .. } => Aabb { - min: [ - start[0].min(end[0]), - start[1].min(end[1]), - start[2].min(end[2]), - ], - max: [ - start[0].max(end[0]), - start[1].max(end[1]), - start[2].max(end[2]), - ], - }, - Curve2Spec::Circle { centre, radius, .. } => Aabb { - min: [centre[0] - radius, centre[1] - radius, centre[2]], - max: [centre[0] + radius, centre[1] + radius, centre[2]], - }, - Curve2Spec::Point { position, .. } => Aabb { - min: *position, - max: *position, - }, - Curve2Spec::Polyline { points, .. } => { - let mut min = [f64::INFINITY; 3]; - let mut max = [f64::NEG_INFINITY; 3]; - for p in points { - for i in 0..3 { - min[i] = min[i].min(p[i]); - max[i] = max[i].max(p[i]); - } - } - Aabb { min, max } - } - Curve2Spec::Arc { centre, radius, .. } => Aabb { - min: [centre[0] - radius, centre[1] - radius, centre[2]], - max: [centre[0] + radius, centre[1] + radius, centre[2]], - }, - Curve2Spec::Ellipse { - centre, major_axis, .. - } => { - let r = (major_axis[0] * major_axis[0] - + major_axis[1] * major_axis[1] - + major_axis[2] * major_axis[2]) - .sqrt(); - Aabb { - min: [centre[0] - r, centre[1] - r, centre[2] - r], - max: [centre[0] + r, centre[1] + r, centre[2] + r], - } - } - Curve2Spec::Spline { control_points, .. } => { - let mut min = [f64::INFINITY; 3]; - let mut max = [f64::NEG_INFINITY; 3]; - for p in control_points { - for i in 0..3 { - min[i] = min[i].min(p[i]); - max[i] = max[i].max(p[i]); - } - } - Aabb { min, max } - } - Curve2Spec::Ray { origin, .. } | Curve2Spec::XLine { origin, .. } => Aabb { - min: *origin, - max: *origin, - }, - } -} - -impl DocApiBackend for MockBackend { - fn resolve_body(&mut self, id: ObjectId) -> ApiResult { - Ok(self.body(id)?.clone()) - } - fn store_solid(&mut self, body: &KernelBody) -> ApiResult { - let id = self.alloc("Solid3D"); - self.bodies.insert(id, body.clone()); - Ok(id) - } - fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> { - if self.bodies.insert(id, body.clone()).is_none() { - return Err(ApiError::UnknownId(id)); - } - Ok(()) - } - fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { - // Validate per-spec layers up front so the op is atomic. - for spec in specs { - if let Some(name) = spec.layer() { - self.lookup_layer(name).map_err(|e| ApiError::validation( - "CreateMany", - e.to_string(), - ))?; - } - } - let mut next = self.clone(); - let ids = specs - .iter() - .map(|spec| match spec { - ocs_doc_api::EntitySpec::Solid(spec) => { - next.store_solid(&ocs_doc_api::geom::make_solid(spec)?) - } - ocs_doc_api::EntitySpec::Curve(spec) => { - ocs_doc_api::convert::curve_spec_to_entity(spec)?; - next.add_curve(spec) - } - }) - .collect::>>()?; - next.push_undo("CreateMany"); - next.finalize_op(); - *self = next; - Ok(ids) - } - fn transform_many(&mut self, ids: &[ObjectId], placement: &PlacementSpec) -> ApiResult<()> { - let mut next = self.clone(); - for &id in ids { - next.transform_entity(id, placement)?; - } - next.push_undo("TransformMany"); - next.finalize_op(); - *self = next; - Ok(()) - } - fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - let kind = match spec { - Curve2Spec::Line { .. } => "Line", - Curve2Spec::Circle { .. } => "Circle", - Curve2Spec::Polyline { .. } => "LwPolyline", - Curve2Spec::Point { .. } => "Point", - Curve2Spec::Arc { .. } => "Arc", - Curve2Spec::Ellipse { .. } => "Ellipse", - Curve2Spec::Spline { .. } => "Spline", - Curve2Spec::Ray { .. } => "Ray", - Curve2Spec::XLine { .. } => "XLine", - }; - let layer = spec - .layer() - .map(|name| { - self.lookup_layer(name).map(|info| info.normalized_name()) - }) - .transpose()?; - let id = self.alloc(kind); - self.curves.insert(id, spec.clone()); - self.entity_layers - .insert(id, layer.unwrap_or_else(|| "0".to_string())); - Ok(id) - } - - fn lookup_layer(&self, name: &str) -> ApiResult { - let norm = normalize_layer_name(name); - if norm.is_empty() { - return Err(ApiError::validation("LookupLayer", "empty layer name")); - } - self.layers - .get(&norm) - .cloned() - .ok_or_else(|| ApiError::validation("LookupLayer", format!("layer '{name}' does not exist"))) - } - fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { - if spec.block_name.is_empty() { - return Err(ApiError::validation("CreateInsert", "empty block name")); - } - Ok(self.alloc("Insert")) - } - fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { - let id = self.alloc("Viewport"); - self.viewport_views - .insert(id, (spec.view_target, spec.view_height)); - Ok(id) - } - fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { - let id = self.alloc("Text"); - self.text_values.insert(id, spec.value.clone()); - Ok(id) - } - fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult { - let id = self.alloc("MText"); - self.text_values.insert(id, spec.value.clone()); - Ok(id) - } - fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { - if let std::collections::hash_map::Entry::Occupied(mut e) = self.text_values.entry(id) { - e.insert(value.to_string()); - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - fn text_content(&self, id: ObjectId) -> ApiResult { - self.text_values - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult { - let id = self.alloc("Hatch"); - self.hatch_boundaries - .insert(id, vec![spec.boundary.clone()]); - Ok(id) - } - fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { - self.hatch_boundaries - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn add_dimension_linear( - &mut self, - spec: &ocs_doc_api::ops::DimensionSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - let d = ((spec.first_point[0] - spec.second_point[0]).powi(2) - + (spec.first_point[1] - spec.second_point[1]).powi(2) - + (spec.first_point[2] - spec.second_point[2]).powi(2)) - .sqrt(); - self.dimension_measurements.insert(id, d); - Ok(id) - } - fn add_dimension_radius( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - let d = ((spec.center[0] - spec.point[0]).powi(2) - + (spec.center[1] - spec.point[1]).powi(2) - + (spec.center[2] - spec.point[2]).powi(2)) - .sqrt(); - self.dimension_measurements.insert(id, d); - Ok(id) - } - fn add_dimension_diameter( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - self.add_dimension_radius(spec) - } - fn add_dimension_angular( - &mut self, - _spec: &ocs_doc_api::ops::DimensionAngularSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - self.dimension_measurements.insert(id, 90.0); - Ok(id) - } - fn dimension_measurement(&self, id: ObjectId) -> ApiResult { - self.dimension_measurements - .get(&id) - .copied() - .ok_or(ApiError::UnknownId(id)) - } - fn add_attribute_definition( - &mut self, - spec: &ocs_doc_api::ops::AttributeDefinitionSpec, - ) -> ApiResult { - if spec.tag.is_empty() { - return Err(ApiError::validation( - "CreateAttributeDefinition", - "empty tag", - )); - } - Ok(self.alloc("AttributeDefinition")) - } - fn add_dimension_angular2ln( - &mut self, - _spec: &ocs_doc_api::ops::DimensionAngularSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - self.dimension_measurements.insert(id, 90.0); - Ok(id) - } - fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { - let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); - if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) { - return Err(ApiError::validation( - "CreateTable", - "table needs a non-empty rectangular grid", - )); - } - Ok(self.alloc("Table")) - } - fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let attrs = self.attributes_store.entry(id).or_default(); - if let Some(a) = attrs.iter_mut().find(|(t, _)| t == tag) { - a.1 = value.to_string(); - } else { - attrs.push((tag.to_string(), value.to_string())); - } - Ok(()) - } - fn attributes(&self, id: ObjectId) -> ApiResult> { - self.attributes_store - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn block_entities(&self, _block_name: &str) -> ApiResult> { - Ok(Vec::new()) - } - fn add_raster_image( - &mut self, - _spec: &ocs_doc_api::ops::RasterImageSpec, - ) -> ApiResult { - Ok(self.alloc("RasterImage")) - } - fn loft( - &mut self, - sections: &[(cadkernel::space::Plane, Vec)], - ) -> ApiResult { - if sections.len() < 2 { - return Err(ApiError::validation("Loft", "loft needs >= 2 profiles")); - } - let body = cadkernel::brep::make::cuboid([0.0, 0.0, 0.0], [1.0; 3]) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "make cuboid"))?; - self.store_solid(&body) - } - fn set_viewport_view( - &mut self, - id: ObjectId, - view_target: [f64; 3], - view_height: f64, - ) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - self.viewport_views.insert(id, (view_target, view_height)); - Ok(()) - } - fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> { - self.viewport_views - .get(&id) - .copied() - .ok_or(ApiError::UnknownId(id)) - } - fn set_xdata( - &mut self, - id: ObjectId, - application_name: &str, - record: Option<&ocs_doc_api::XDataRecord>, - ) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let key = (id, application_name.to_string()); - match record { - Some(r) => { - self.xdata_store.insert(key, r.clone()); - } - None => { - self.xdata_store.remove(&key); - } - } - Ok(()) - } - fn xdata( - &self, - id: ObjectId, - application_name: &str, - ) -> ApiResult> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - Ok(self - .xdata_store - .get(&(id, application_name.to_string())) - .cloned()) - } - fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult { - let id = self.alloc("XRecord"); - self.xrecord_store.insert(id, spec.clone()); - Ok(id) - } - fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "SetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - self.xrecord_store.insert(id, spec.clone()); - Ok(()) - } - fn xrecord(&self, id: ObjectId) -> ApiResult> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "GetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - Ok(self.xrecord_store.get(&id).cloned()) - } - fn create_layer(&mut self, info: &LayerInfo) -> ApiResult<()> { - let name = normalize_layer_name(&info.name); - if name.is_empty() { - return Err(ApiError::validation("CreateLayer", "empty layer name")); - } - if self.layers.contains_key(&name) { - return Err(ApiError::validation( - "CreateLayer", - format!("layer '{name}' already exists"), - )); - } - let mut stored = info.clone(); - stored.name = name.clone(); - self.layers.insert(name, stored); - Ok(()) - } - fn update_layer(&mut self, name: &str, info: &LayerInfo) -> ApiResult<()> { - let name_upper = normalize_layer_name(name); - if !self.layers.contains_key(&name_upper) { - return Err(ApiError::validation( - "UpdateLayer", - format!("layer '{name}' does not exist"), - )); - } - let mut stored = info.clone(); - stored.name = name_upper.clone(); - self.layers.insert(name_upper, stored); - Ok(()) - } - fn delete_layer(&mut self, name: &str) -> ApiResult<()> { - let name_upper = normalize_layer_name(name); - if name_upper == "0" { - return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0")); - } - if self.entity_layers.values().any(|l| normalize_layer_name(l) == name_upper) { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name_upper}' still in use"), - )); - } - if self.layers.remove(&name_upper).is_none() { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name}' does not exist"), - )); - } - Ok(()) - } - fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let layer_upper = normalize_layer_name(layer); - let target = self - .layers - .get(&layer_upper) - .ok_or_else(|| { - ApiError::validation( - "SetEntityLayer", - format!("layer '{layer_upper}' does not exist"), - ) - })?; - if target.flags.locked { - return Err(ApiError::Unsupported(format!( - "entity {id:?} cannot be moved to locked layer '{layer_upper}'" - ))); - } - self.entity_layers.insert(id, layer_upper); - Ok(()) - } - fn layers(&self) -> ApiResult> { - let mut v: Vec<_> = self.layers.values().cloned().collect(); - v.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(v) - } - fn entity_layer(&self, id: ObjectId) -> ApiResult { - self.entity_layers - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { - if self.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - fn remove_entity(&mut self, id: ObjectId) -> ApiResult { - self.bodies.remove(&id); - self.xdata_store.retain(|(k, _), _| *k != id); - self.xrecord_store.remove(&id); - self.entity_layers.remove(&id); - Ok(self.kinds.remove(&id).is_some()) - } - fn enumerate_entities( - &self, - kind: Option<&str>, - layer: Option<&str>, - include_bounds: bool, - ) -> ApiResult> { - let mut out = Vec::new(); - for (id, kind_name) in &self.kinds { - if let Some(k) = kind { - if !k.eq_ignore_ascii_case(kind_name) { - continue; - } - } - let entity_layer = self - .entity_layers - .get(id) - .cloned() - .unwrap_or_else(|| "0".to_string()); - if let Some(l) = layer { - if !l.eq_ignore_ascii_case(&entity_layer) { - continue; - } - } - let bounds = if include_bounds { - self.curves.get(id).map(curve_bounds) - } else { - None - }; - out.push(EntityView { - id: *id, - kind: kind_name.clone(), - layer: entity_layer, - bounds, - }); - } - Ok(out) - } - fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> { - let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; - match spec { - Curve2Spec::Point { position, .. } => Ok(*position), - _ => Err(ApiError::Unsupported( - "GetPointPosition is only for Point entities".into(), - )), - } - } - fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> { - let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; - match spec { - Curve2Spec::Line { start, end, .. } => Ok((*start, *end)), - _ => Err(ApiError::Unsupported( - "GetLineGeometry is only for Line entities".into(), - )), - } - } - fn get_entity(&mut self, id: ObjectId) -> ApiResult { - let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone(); - let layer = self - .entity_layers - .get(&id) - .cloned() - .unwrap_or_else(|| "0".to_string()); - let bounds = self.bodies.get(&id).and_then(|b| { - cadkernel::brep::body_bounds(b).map(|bb| Aabb { - min: bb.min, - max: bb.max, - }) - }); - Ok(EntityView { - id, - kind, - layer, - bounds, - }) - } - fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - if let Some(body) = self.bodies.get(&id).cloned() { - let place = cadkernel::brep::Placement { - x_axis: placement.x_axis, - y_axis: placement.y_axis, - z_axis: placement.z_axis, - origin: placement.origin, - }; - let moved = cadkernel::brep::transform(&body, &place).ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed") - })?; - self.bodies.insert(id, moved); - } - Ok(()) - } - fn profile_curves(&self, _id: ObjectId) -> ApiResult> { - Err(ApiError::Unsupported("profiles not mocked".into())) - } - fn bounds(&mut self, id: ObjectId) -> ApiResult { - // Solids via the kernel body; 2D curves from their stored spec. - if let Some(body) = self.bodies.get(&id) { - let bb = cadkernel::brep::body_bounds(body) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?; - return Ok(Aabb { - min: bb.min, - max: bb.max, - }); - } - if let Some(spec) = self.curves.get(&id) { - return Ok(curve_bounds(spec)); - } - Err(ApiError::UnknownId(id)) - } - fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { - let body = self.body(id)?; - Ok(mesh_vc(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).1) - } - fn volume(&mut self, id: ObjectId) -> ApiResult { - let body = self.body(id)?; - Ok(mesh_vc(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).0) - } - fn entity_exists(&self, id: ObjectId) -> bool { - self.kinds.contains_key(&id) - } - fn object_exists(&self, id: ObjectId) -> bool { - self.xrecord_store.contains_key(&id) - } - fn revision(&self) -> GeometryRevision { - GeometryRevision(self.revision) - } - fn push_undo(&mut self, _label: &str) {} - fn finalize_op(&mut self) { - self.undo_steps += 1; - self.revision += 1; - } -} - -/// Signed volume + centroid of a closed mesh — delegates to the crate's single -/// source of truth (divergence theorem) so the example mock can't drift. -fn mesh_vc(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { - ocs_doc_api::geom::mesh_volume_centroid(mesh) -} diff --git a/crates/ocs_doc_api/examples/e1_primitives_and_queries.rs b/crates/ocs_doc_api/examples/e1_primitives_and_queries.rs deleted file mode 100644 index 3d3312f11..000000000 --- a/crates/ocs_doc_api/examples/e1_primitives_and_queries.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Example 1 — Create solid primitives and query their geometry. -//! -//! Run: `cargo run --example e1_primitives_and_queries --features host` -//! -//! Shows: `doc.solids().create_*` for the supported primitives, and the read -//! queries `bounds()` / `volume()` / `centroid()` on a `Solid`. - -mod common; - -use ocs_doc_api::DocApi; - -fn main() -> ocs_doc_api::ApiResult<()> { - // In-process document over the mock backend (no host binary needed). - let api = DocApi::in_process(common::MockBackend::default(), 0); - let doc = api.document(api.active_tab()); - - // The supported solid primitives. - let block = doc - .solids() - .create_cuboid([0.0, 0.0, 0.0], [10.0, 10.0, 10.0])?; - let ball = doc.solids().create_sphere([5.0, 5.0, 5.0], 6.0)?; - let cyl = doc.solids().create_cylinder([0.0, 0.0, 0.0], 4.0, 12.0)?; - - // Geometric queries are entity methods — one host call each. - let bb = block.bounds()?; - let vol = ball.volume()?; - let cg = cyl.centroid()?; - - println!("cuboid bounds : {bb:?}"); - println!("sphere volume : {vol:.2} (analytic ~904.78)"); - println!("cylinder centroid: {cg:?}"); - - // Read-only batching: same queries, ONE round-trip. - let res = doc.query_batch(|q| { - q.bounds(&block); - q.volume(&ball); - q.centroid(&cyl); - })?; - let (bb2, vol2, cg2) = (res.bounds(0)?, res.volume(1)?, res.centroid(2)?); - assert_eq!(bb, bb2); - assert!((vol - vol2).abs() < 1e-9); - assert_eq!(cg, cg2); - println!("query_batch matches the per-call results"); - Ok(()) -} diff --git a/crates/ocs_doc_api/examples/e2_intersect_two_solids.rs b/crates/ocs_doc_api/examples/e2_intersect_two_solids.rs deleted file mode 100644 index dd117c6fd..000000000 --- a/crates/ocs_doc_api/examples/e2_intersect_two_solids.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Example 2 — Construct two solids and intersect them (the canonical boolean). -//! -//! Run: `cargo run --example e2_intersect_two_solids --features host` -//! -//! Shows: `intersect` / `union` / `subtract` on `Solid`, `erase_sources` -//! semantics (the result lives at the first input's id, the second is erased), -//! and `OpGroup` cleanup on failure. - -mod common; - -use ocs_doc_api::{DocApi, HasId, OpGroup}; - -fn main() -> ocs_doc_api::ApiResult<()> { - let api = DocApi::in_process(common::MockBackend::default(), 0); - let doc = api.document(api.active_tab()); - let mut grp = OpGroup::new(); - - // Two overlapping boxes: [0,10]^3 and [5,15]^3. - let a = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3]))?; - let b = grp.track(doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3]))?; - - // intersect -> SolidBoolean{Intersection, erase_sources:true}. The result is - // returned AT `a`'s id; `b` is erased. [5,10]^3 = volume 125. - let lens = match a.intersect(&b) { - Ok(l) => { - grp.commit(); // keep everything - l - } - Err(e) => { - grp.compensate(&doc)?; // best-effort: delete the two boxes - return Err(e); - } - }; - - assert_eq!(lens.id(), a.id(), "result lives at the first input's id"); - let vol = lens.volume()?; - let bb = lens.bounds()?; - println!("intersection volume = {vol:.2} (expected 125)"); - println!("intersection bounds = {bb:?}"); - - // union / subtract follow the same shape. Fresh inputs (b was erased above). - let c = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; - let d = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?; - let uni = c.union(&d)?; - // union volume = 1000 + 1000 - 125 (overlap) = 1875. - println!("union volume = {:.2} (expected 1875)", uni.volume()?); - - let e = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; - let f = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?; - let diff = e.subtract(&f)?; - println!("difference volume = {:.2} (expected 875)", diff.volume()?); - Ok(()) -} diff --git a/crates/ocs_doc_api/examples/e3_transform_and_chain.rs b/crates/ocs_doc_api/examples/e3_transform_and_chain.rs deleted file mode 100644 index 1229f2997..000000000 --- a/crates/ocs_doc_api/examples/e3_transform_and_chain.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Example 3 — Transform a solid and chain geometric operations. -//! -//! Run: `cargo run --example e3_transform_and_chain --features host` -//! -//! Shows: `transform(PlacementSpec)` (in-place, same ObjectId), how bounds shift -//! exactly by the translation, and chaining intersect -> transform. - -mod common; - -use ocs_doc_api::{DocApi, PlacementSpec}; - -fn main() -> ocs_doc_api::ApiResult<()> { - let api = DocApi::in_process(common::MockBackend::default(), 0); - let doc = api.document(api.active_tab()); - - let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; - let b = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?; - - // Boolean first: lens = [5,10]^3. - let lens = a.intersect(&b)?; - let before = lens.bounds()?; - println!("lens bounds before transform: {before:?}"); - - // Transform is in-place: the SAME ObjectId is preserved; bounds shift by the - // translation. [5,10]^3 moved +100 in X -> [105,110] x [5,10] x [5,10]. - lens.transform(PlacementSpec::at([100.0, 0.0, 0.0]))?; - let after = lens.bounds()?; - println!("lens bounds after +100 X : {after:?}"); - assert!((after.min[0] - 105.0).abs() < 1e-4); - assert!((after.max[0] - 110.0).abs() < 1e-4); - assert_eq!(after.min[1], before.min[1], "Y unchanged"); - - // Volume is invariant under a rigid transform. - let vol = lens.volume()?; - println!("lens volume after transform = {vol:.2} (still ~125)"); - assert!((vol - 125.0).abs() < 1.0); - Ok(()) -} diff --git a/crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs b/crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs deleted file mode 100644 index eb1ff0b5b..000000000 --- a/crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Example 4 — 2D entities and bulk creation. -//! -//! Run: `cargo run --example e4_2d_entities_and_bulk --features host` -//! -//! Shows: the minimal-2D constructors (`create_line`/`create_circle`/ -//! `create_polyline`/`create_point`), `bounds()` on 2D entities, and the bulk op -//! `create_points` (one op, all-or-nothing, one undo step). - -mod common; - -use ocs_doc_api::{DocApi, HasId}; - -fn main() -> ocs_doc_api::ApiResult<()> { - let api = DocApi::in_process(common::MockBackend::default(), 0); - let doc = api.document(api.active_tab()); - - // Individual 2D constructors. - let line = doc - .curves() - .create_line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])?; - let circle = doc.curves().create_circle([5.0, 5.0, 0.0], 3.0)?; - let poly = doc.curves().create_polyline( - &[ - [0.0, 0.0, 0.0], - [1.0, 0.0, 0.0], - [1.0, 1.0, 0.0], - [0.0, 1.0, 0.0], - ], - true, - )?; - - println!("line bounds: {:?}", line.bounds()?); // [0,10] in X - println!("circle bounds: {:?}", circle.bounds()?); // centre ± radius - println!("poly bounds: {:?}", poly.bounds()?); // unit square - - // Bulk: 1000 points in ONE op (all-or-nothing, one undo step — not 1000). - let coords: Vec<[f64; 3]> = (0..1000) - .map(|i| [i as f64, (i % 10) as f64, 0.0]) - .collect(); - let pts = doc.curves().create_points(&coords)?; - println!("created {} points in one bulk op", pts.len()); - assert_eq!(pts.len(), 1000); - - // Generic lookup: any id -> Entity -> kind. - let e = doc.entities().get(line.id())?; - println!("line entity kind: {}", e.view()?.kind); - - Ok(()) -} diff --git a/crates/ocs_doc_api/examples/e5_failure_and_guards.rs b/crates/ocs_doc_api/examples/e5_failure_and_guards.rs deleted file mode 100644 index 2cc82dbe5..000000000 --- a/crates/ocs_doc_api/examples/e5_failure_and_guards.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Example 5 — Failure semantics: atomicity, OpGroup compensation, revision guard. -//! -//! Run: `cargo run --example e5_failure_and_guards --features host` -//! -//! Shows: per-op atomicity (a failed op applies nothing and moves no revision), -//! `OpGroup` best-effort cleanup, `ApiError` handling, and the `assert_revision` -//! read-guard. - -mod common; - -use ocs_doc_api::{ApiError, DocApi, HasId, ObjectId, OpGroup}; - -fn main() -> ocs_doc_api::ApiResult<()> { - let api = DocApi::in_process(common::MockBackend::default(), 0); - let doc = api.document(api.active_tab()); - - // ── Per-op atomicity: a boolean against a deleted id fails cleanly ── - let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; - let ghost = doc.solids().create_sphere([5.0, 5.0, 5.0], 4.0)?; - ghost.delete()?; // now a stale handle - - let rev_before = doc.revision()?; - match a.intersect(&ghost) { - Err(ApiError::UnknownId(id)) => println!("boolean failed as expected: unknown id {id:?}"), - Err(e) => println!("boolean failed: {e}"), - Ok(_) => panic!("expected the boolean to fail"), - } - // `a` is still live (failed op mutated nothing) and the revision did not move. - assert!(a.bounds().is_ok(), "input solid survives a failed boolean"); - assert_eq!( - doc.revision()?, - rev_before, - "failed op records no revision bump" - ); - - // ── OpGroup: clean up a partially-built logical operation on failure ── - let mut grp = OpGroup::new(); - let p1 = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [4.0; 3]))?; - let p2 = grp.track(doc.solids().create_sphere([2.0, 2.0, 2.0], 3.0))?; - // Simulate a downstream failure: abandon the group and delete what we made. - grp.compensate(&doc)?; - for h in [p1.id(), p2.id()] { - assert!(doc.entities().get(h).is_err(), "compensated entity is gone"); - } - println!("OpGroup compensated: created entities deleted"); - - // ── assert_revision: read-modify-write guard ── - let rev = doc.revision()?; - doc.assert_revision(rev)?; // unchanged -> ok - doc.solids().create_cuboid([0.0, 0.0, 0.0], [1.0; 3])?; // moves the revision - match doc.assert_revision(rev) { - Err(ApiError::Validation { reason, .. }) => println!("revision guard caught: {reason}"), - other => panic!("expected the revision guard to fail, got {other:?}"), - } - - // A totally unknown id also surfaces a structured error. - assert!(doc.entities().get(ObjectId::from_u64(0xDEAD)).is_err()); - println!("failure semantics verified"); - Ok(()) -} diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml deleted file mode 100644 index 4de4cf6eb..000000000 --- a/crates/ocs_doc_api/spec/entities.toml +++ /dev/null @@ -1,566 +0,0 @@ -# Curated source of truth for ocs_doc_api codegen (plan §10, decision #6). -# Maps entity families + their construction ops, geometric actions, and queries -# to kernel fns / acadrust variants. build.rs regenerates src/gen/* from this; -# CI fails on drift. New op/query variants append at the END only (bincode -# discriminant stability). -# -# `status` values: "supported" (default), "unsupported" (present but errors), -# "planned" (roadmap; no callable surface). Generic handle methods -# (bounds/transform/delete) are documented once in build.rs and apply to every -# family unless overridden here. - -[meta] -envelope_version = 1 - -# ============================================================================ -# SOLID FAMILY (phase 1) — B-rep solids via cadkernel -# ============================================================================ -[[family]] -name = "Solid" -acadrust_variant = "Solid3D" -handle = "Solid" -collection = "solids" -description = "B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from ACIS SAT)." - -[[family.constructor]] -name = "create_cuboid" -args = [{ name = "origin", ty = "[f64; 3]" }, { name = "size", ty = "[f64; 3]" }] -op = "CreateSolid" -fixed = { primitive = "Cuboid" } - -[[family.constructor]] -name = "create_sphere" -args = [{ name = "centre", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }] -op = "CreateSolid" -fixed = { primitive = "Sphere" } - -[[family.constructor]] -name = "create_cylinder" -args = [{ name = "base", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }, { name = "height", ty = "f64" }] -op = "CreateSolid" -fixed = { primitive = "Cylinder" } - -[[family.constructor]] -name = "create_cone" -args = [{ name = "base", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }, { name = "height", ty = "f64" }] -op = "CreateSolid" -fixed = { primitive = "Cone" } - -[[family.constructor]] -name = "create_torus" -args = [{ name = "centre", ty = "[f64; 3]" }, { name = "major_radius", ty = "f64" }, { name = "minor_radius", ty = "f64" }] -op = "CreateSolid" -fixed = { primitive = "Torus" } - -[[family.constructor]] -name = "create_wedge" -args = [{ name = "origin", ty = "[f64; 3]" }, { name = "size", ty = "[f64; 3]" }] -op = "CreateSolid" -fixed = { primitive = "Wedge" } - -[[family.constructor]] -name = "extrude" -args = [{ name = "profile", ty = "EntityRef" }, { name = "direction", ty = "[f64; 3]" }] -op = "Extrude" - -[[family.constructor]] -name = "revolve" -args = [{ name = "profile", ty = "EntityRef" }, { name = "pivot", ty = "[f64; 3]" }, { name = "axis", ty = "[f64; 3]" }, { name = "angle", ty = "f64" }] -op = "Revolve" - -[[family.constructor]] -name = "loft" -args = [{ name = "profiles", ty = "&[EntityRef]" }] -op = "Loft" - -[[family.method]] -name = "intersect" -kind = "op" -op = "SolidBoolean" -args = [{ name = "other", ty = "Solid" }] -returns = "Solid" -fixed = { op = "Intersection", erase_sources = true } - -[[family.method]] -name = "union" -kind = "op" -op = "SolidBoolean" -args = [{ name = "other", ty = "Solid" }] -returns = "Solid" -fixed = { op = "Union", erase_sources = true } - -[[family.method]] -name = "subtract" -kind = "op" -op = "SolidBoolean" -args = [{ name = "other", ty = "Solid" }] -returns = "Solid" -fixed = { op = "Difference", erase_sources = true } - -[[family.method]] -name = "volume" -kind = "query" -query = "GetVolume" -returns = "f64" - -[[family.method]] -name = "centroid" -kind = "query" -query = "GetCentroid" -returns = "[f64; 3]" - -[[family.method]] -name = "intersects" -kind = "query" -query = "GetIntersects" -args = [{ name = "other", ty = "Solid" }] -returns = "bool" - -# ============================================================================ -# CURVE FAMILY (phase 1, minimal 2D) -# ============================================================================ -[[family]] -name = "Line" -acadrust_variant = "Line" -handle = "Line" -collection = "curves" - -[[family.constructor]] -name = "create_line" -args = [{ name = "start", ty = "[f64; 3]" }, { name = "end", ty = "[f64; 3]" }] -op = "CreateCurve" -fixed = { curve = "Line" } - -[[family]] -name = "Circle" -acadrust_variant = "Circle" -handle = "Circle" -collection = "curves" - -[[family.constructor]] -name = "create_circle" -args = [{ name = "centre", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }] -op = "CreateCurve" -fixed = { curve = "Circle" } - -[[family]] -name = "Polyline" -acadrust_variant = "LwPolyline" -handle = "Polyline" -collection = "curves" - -[[family.constructor]] -name = "create_polyline" -args = [{ name = "points", ty = "&[[f64; 3]]" }, { name = "closed", ty = "bool" }] -op = "CreateCurve" -fixed = { curve = "Polyline" } - -[[family.method]] -name = "add_vertex" -kind = "op" -op = "AddVertex" -args = [{ name = "at", ty = "usize" }, { name = "point", ty = "[f64; 3]" }] -returns = "()" - -[[family]] -name = "Point" -acadrust_variant = "Point" -handle = "Point" -collection = "curves" - -[[family.constructor]] -name = "create_point" -args = [{ name = "position", ty = "[f64; 3]" }] -op = "CreateCurve" -fixed = { curve = "Point" } - -[[family.constructor]] -name = "create_points" -args = [{ name = "positions", ty = "&[[f64; 3]]" }] -op = "CreateMany" -fixed = { curve = "Point" } -bulk = true -returns = "Vec" - -[[family]] -name = "Arc" -acadrust_variant = "Arc" -handle = "ArcCurve" -collection = "curves" - -[[family.constructor]] -name = "create_arc" -args = [{ name = "centre", ty = "[f64; 3]" }, { name = "radius", ty = "f64" }, { name = "start_angle", ty = "f64" }, { name = "end_angle", ty = "f64" }] -op = "CreateCurve" -fixed = { curve = "Arc" } - -[[family]] -name = "Ellipse" -acadrust_variant = "Ellipse" -handle = "Ellipse" -collection = "curves" - -[[family.constructor]] -name = "create_ellipse" -args = [{ name = "centre", ty = "[f64; 3]" }, { name = "major_axis", ty = "[f64; 3]" }, { name = "ratio", ty = "f64" }, { name = "start", ty = "f64" }, { name = "end", ty = "f64" }] -op = "CreateCurve" -fixed = { curve = "Ellipse" } - -[[family]] -name = "Spline" -acadrust_variant = "Spline" -handle = "Spline" -collection = "curves" - -[[family.constructor]] -name = "create_spline" -args = [{ name = "degree", ty = "i32" }, { name = "control_points", ty = "&[[f64; 3]]" }, { name = "knots", ty = "&[f64]" }, { name = "weights", ty = "&[f64]" }] -op = "CreateCurve" -fixed = { curve = "Spline" } - -[[family]] -name = "Ray" -acadrust_variant = "Ray" -handle = "Ray" -collection = "curves" - -[[family.constructor]] -name = "create_ray" -args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64; 3]" }] -op = "CreateCurve" -fixed = { curve = "Ray" } - -[[family]] -name = "XLine" -acadrust_variant = "XLine" -handle = "XLine" -collection = "curves" - -[[family.constructor]] -name = "create_xline" -args = [{ name = "origin", ty = "[f64; 3]" }, { name = "direction", ty = "[f64; 3]" }] -op = "CreateCurve" -fixed = { curve = "XLine" } - -# ============================================================================ -# ANNOTATION FAMILY (phase 2b) — text annotations -# ============================================================================ -[[family]] -name = "Text" -acadrust_variant = "Text" -handle = "Text" -collection = "curves" -description = "Single-line text annotation. content()/set_content() read+replace the value; transform moves insertion point." - -[[family.constructor]] -name = "create_text" -args = [{ name = "value", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "height", ty = "f64" }, { name = "rotation", ty = "f64" }] -op = "CreateText" - -[[family.method]] -name = "content" -kind = "query" -query = "GetTextContent" -returns = "String" - -[[family.method]] -name = "set_content" -kind = "op" -op = "SetTextContent" -args = [{ name = "value", ty = "&str" }] -returns = "()" - -[[family]] -name = "MText" -acadrust_variant = "MText" -handle = "MText" -collection = "curves" -description = "Multi-line text annotation. content()/set_content() read+replace the value; transform moves insertion point." - -[[family.constructor]] -name = "create_mtext" -args = [{ name = "value", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "height", ty = "f64" }] -op = "CreateMText" - -[[family.method]] -name = "content" -kind = "query" -query = "GetTextContent" -returns = "String" - -[[family.method]] -name = "set_content" -kind = "op" -op = "SetTextContent" -args = [{ name = "value", ty = "&str" }] -returns = "()" - -# ============================================================================ -# HATCH FAMILY (phase 2b) — filled regions -# ============================================================================ -[[family]] -name = "Hatch" -acadrust_variant = "Hatch" -handle = "Entity" -collection = "curves" -description = "Filled region over a closed polyline boundary. boundary() returns the loops; bounds from boundary; generic delete." - -[[family.constructor]] -name = "create_hatch" -args = [{ name = "boundary", ty = "&[[f64; 2]]" }, { name = "solid", ty = "bool" }] -op = "CreateHatch" - -[[family.method]] -name = "boundary" -kind = "query" -query = "GetHatchBoundary" -returns = "Vec>" - -# ============================================================================ -# DIMENSION FAMILY (phase 2c) — linear dimensions -# ============================================================================ -[[family]] -name = "Dimension" -acadrust_variant = "Dimension" -handle = "Dimension" -collection = "curves" -description = "Linear dimension between two measured points. measurement() reads the distance; bounds from the measured + definition points." - -[[family.constructor]] -name = "create_dimension_linear" -args = [{ name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "definition_point", ty = "[f64; 3]" }] -op = "CreateDimensionLinear" - -[[family.constructor]] -name = "create_dimension_radius" -args = [{ name = "center", ty = "[f64; 3]" }, { name = "point", ty = "[f64; 3]" }] -op = "CreateDimensionRadius" - -[[family.constructor]] -name = "create_dimension_diameter" -args = [{ name = "center", ty = "[f64; 3]" }, { name = "point", ty = "[f64; 3]" }] -op = "CreateDimensionDiameter" - -[[family.constructor]] -name = "create_dimension_angular" -args = [{ name = "vertex", ty = "[f64; 3]" }, { name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "arc_location", ty = "[f64; 3]" }] -op = "CreateDimensionAngular" - -[[family.constructor]] -name = "create_dimension_angular2ln" -args = [{ name = "vertex", ty = "[f64; 3]" }, { name = "first_point", ty = "[f64; 3]" }, { name = "second_point", ty = "[f64; 3]" }, { name = "arc_location", ty = "[f64; 3]" }] -op = "CreateDimensionAngular2Ln" - -[[family.method]] -name = "measurement" -kind = "query" -query = "GetDimensionMeasurement" -returns = "f64" - -# ============================================================================ -# CONTAINER FAMILY (phase 3) — block references -# ============================================================================ -[[family]] -name = "Insert" -acadrust_variant = "Insert" -handle = "Entity" -collection = "entities" -description = "Block reference. Place an existing BlockRecord by name; transform moves its insert point." - -[[family.constructor]] -name = "create_insert" -args = [{ name = "block_name", ty = "&str" }, { name = "insert_point", ty = "[f64; 3]" }, { name = "scale", ty = "f64" }, { name = "rotation", ty = "f64" }] -op = "CreateInsert" - -[[family.method]] -name = "attributes" -kind = "query" -query = "GetAttributes" -returns = "Vec<(String, String)>" - -[[family.method]] -name = "set_attribute" -kind = "op" -op = "SetAttribute" -args = [{ name = "tag", ty = "&str" }, { name = "value", ty = "&str" }] -returns = "()" - -# ============================================================================ -# ATTRIBUTE-DEFINITION FAMILY (phase 3-ii) — in-block attribute definitions -# ============================================================================ -[[family]] -name = "AttributeDefinition" -acadrust_variant = "AttributeDefinition" -handle = "Entity" -collection = "entities" -description = "In-block attribute definition (ATTDEF): tag/prompt/default at an insertion point." - -[[family.constructor]] -name = "create_attribute_definition" -args = [{ name = "tag", ty = "&str" }, { name = "prompt", ty = "&str" }, { name = "default_value", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "height", ty = "f64" }, { name = "rotation", ty = "f64" }] -op = "CreateAttributeDefinition" - -# ============================================================================ -# PAPER-SPACE FAMILY (phase 4) — viewports -# ============================================================================ -[[family]] -name = "Viewport" -acadrust_variant = "Viewport" -handle = "Entity" -collection = "entities" -description = "Paper-space viewport: a width×height window at center looking at view_target with view_height zoom. bounds = center ± w/2,h/2; transform moves center." - -[[family.constructor]] -name = "create_viewport" -args = [{ name = "center", ty = "[f64; 3]" }, { name = "width", ty = "f64" }, { name = "height", ty = "f64" }, { name = "view_target", ty = "[f64; 3]" }, { name = "view_height", ty = "f64" }] -op = "CreateViewport" - -[[family.method]] -name = "viewport_view" -kind = "query" -query = "GetViewportView" -returns = "([f64; 3], f64)" - -[[family.method]] -name = "set_view" -kind = "op" -op = "SetViewportView" -args = [{ name = "view_target", ty = "[f64; 3]" }, { name = "view_height", ty = "f64" }] -returns = "()" - -# ============================================================================ -# MEDIA & MISC (phase 5, read-mostly) — read-only EntityView, no typed create. -# These families report a real `kind` via GetEntity and coarse bounds where -# computable; they have no typed construction/geometric ops in v1. -# ============================================================================ -[[family]] -name = "RasterImage" -acadrust_variant = "RasterImage" -handle = "Entity" -collection = "entities" -description = "Raster image: create_raster_image(file_path, insertion, u, v, size) places an image (host auto-registers the ImageDefinition); bounds (4-corner bracket); delete." - -[[family.constructor]] -name = "create_raster_image" -args = [{ name = "file_path", ty = "&str" }, { name = "insertion_point", ty = "[f64; 3]" }, { name = "u_vector", ty = "[f64; 3]" }, { name = "v_vector", ty = "[f64; 3]" }, { name = "size", ty = "[f64; 2]" }] -op = "CreateRasterImage" - -[[family]] -name = "Table" -acadrust_variant = "Table" -handle = "Entity" -collection = "entities" -description = "Table: create_table(insertion_point, data[row][col]) builds rows x columns of text cells; non-rectangular grid -> Validation; delete." - -[[family.constructor]] -name = "create_table" -args = [{ name = "insertion_point", ty = "[f64; 3]" }, { name = "data", ty = "&[Vec]" }] -op = "CreateTable" - -# ============================================================================ -# XDATA / XRECORD FAMILY — application-specific extended data roundtrip -# ============================================================================ -[[family]] -name = "XRecord" -acadrust_variant = "XRecord" -handle = "XRecord" -collection = "entities" -description = "Extended record (XRecord): arbitrary DXF group-code/value pairs. Roundtripped verbatim as an XRecordSpec payload." - -[[family.constructor]] -name = "create_xrecord" -args = [{ name = "spec", ty = "XRecordSpec" }] -op = "CreateXRecord" -returns = "XRecord" - -[[family.method]] -name = "payload" -kind = "query" -query = "GetXRecord" -returns = "XRecordSpec" - -[[family.method]] -name = "set_payload" -kind = "op" -op = "SetXRecord" -args = [{ name = "spec", ty = "XRecordSpec" }] -returns = "()" - -# ============================================================================ -# Cross-cutting ops (generic handles + bulk) — documented in build.rs -# ============================================================================ -# - Generic handle methods on EVERY handle: bounds() -> Aabb, transform(placement), -# delete(), id() -> ObjectId. (transform is solids-only in v1.) -# - EntityCollection: get(id) -> Entity, delete(id), transform_many(&ids, pl), -# delete_many(&ids). -# - Bulk ops (CreateMany / TransformMany / DeleteMany): single op, all-or-nothing -# via upfront validation, item-capped (BULK_ITEM_CAP). -# - Read-only batching: query_batch(|q| ...) — one round-trip, no revision bump. -# - OpGroup: best-effort client-side failure cleanup (NOT a transaction). - -# ============================================================================ -# LAYER family (named drawing layers) -# ============================================================================ -[[family]] -name = "Layer" -handle = "Layer" -collection = "layers" -description = "Named drawing layer (color, linetype, lineweight, visibility). Not an entity handle; accessed through Document::layers()." - -[[family.method]] -name = "list" -kind = "query" -query = "ListLayers" -returns = "Vec" - -[[family.method]] -name = "create" -kind = "op" -op = "CreateLayer" -args = [{ name = "info", ty = "LayerInfo" }] -returns = "()" - -[[family.method]] -name = "update" -kind = "op" -op = "UpdateLayer" -args = [{ name = "name", ty = "String" }, { name = "info", ty = "LayerInfo" }] -returns = "()" - -[[family.method]] -name = "delete" -kind = "op" -op = "DeleteLayer" -args = [{ name = "name", ty = "String" }] -returns = "()" - -# ---- Kernel/op mapping (reference for the executor + docs) ---- -[[kernel_map]] -op = "CreateSolid.Cuboid" -kernel = "cadkernel::brep::make::cuboid" -returns = "Option" - -[[kernel_map]] -op = "CreateSolid.Sphere" -kernel = "cadkernel::brep::make::sphere" -returns = "Option" - -[[kernel_map]] -op = "SolidBoolean" -kernel = "cadkernel::brep::combine" -returns = "Result" - -[[kernel_map]] -op = "Transform" -kernel = "cadkernel::brep::transform" -returns = "Option" - -[[kernel_map]] -op = "GetBounds" -kernel = "cadkernel::brep::body_bounds" -returns = "Option" - -[[kernel_map]] -op = "GetVolume" -kernel = "cadkernel::brep::mesh_body + divergence" -returns = "computed" diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs deleted file mode 100644 index 51e4cfafe..000000000 --- a/crates/ocs_doc_api/src/backend.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! The thin host hook. The host implements this trait; -//! the crate's [`crate::executor`] drives it. Both the in-process transport and -//! the host's IPC executor call into it. Logic lives in the versioned crate; the -//! host only provides scene primitives. - -use crate::error::{ApiError, ApiResult}; -use crate::id::ObjectId; -use crate::ops::{Curve2Spec, LayerInfo}; -use crate::query::{Aabb, EntityView}; -use crate::revision::GeometryRevision; - -/// A kernel B-rep solid body, opaque to the wire. On `host` this is -/// `cadkernel::brep::Body`; kernel keys never cross the API (decision #7). -pub type KernelBody = cadkernel::brep::Body; - -/// The per-op host hook. Every method maps to an already-present scene primitive. -/// The executor calls `push_undo` BEFORE applying a write op and `finalize_op` -/// AFTER — recording exactly one undo step + one geometry bump per applied op. -pub trait DocApiBackend { - // ── identity / solid lookup ──────────────────────────────────────────── - /// Resolve a live B-rep body for `id` (solid cache; re-lift from AcisData on - /// miss). `UnknownId` if `id` is not a live solid. NOTE: returns an OWNED body - /// (a clone of the cache entry); use `with_body` for read-only queries. - fn resolve_body(&mut self, id: ObjectId) -> ApiResult; - - /// Borrow the cached body read-only for a query, avoiding the `resolve_body` - /// deep clone. Default: falls back to `resolve_body` (clone). The host overrides - /// this to borrow the `solid_models` cache in place (O(1), no copy). - fn with_body( - &mut self, - id: ObjectId, - f: &mut dyn FnMut(&KernelBody) -> ApiResult, - ) -> ApiResult { - let body = self.resolve_body(id)?; - f(&body) - } - - /// Store a new solid body as a `Solid3D` entity; returns the fresh `ObjectId`. - /// Mirrors host `add_solid_model` (edge wires + `solid_to_sat` + - /// `set_sat_document` + `register_solid_model`). - fn store_solid(&mut self, body: &KernelBody) -> ApiResult; - - /// Replace the body of an existing solid in place (same `ObjectId`). - /// Re-embeds SAT and refreshes the solid cache. - fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()>; - - // ── plain (2D) entities ──────────────────────────────────────────────── - /// Add a 2D curve entity; returns the fresh `ObjectId`. - fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult; - - /// Prepare every entity before recording one undo step and committing the batch. - fn create_many(&mut self, specs: &[crate::ops::EntitySpec]) -> ApiResult>; - - /// Prepare all transformed geometry before committing any entity. - fn transform_many( - &mut self, - ids: &[ObjectId], - placement: &crate::PlacementSpec, - ) -> ApiResult<()>; - - /// Add a block reference (`INSERT`). Validates `block_name` exists; returns - /// the fresh `ObjectId`. `Validation` if the block is unknown. - fn add_insert(&mut self, spec: &crate::ops::InsertSpec) -> ApiResult; - - /// Add a paper-space `VIEWPORT`; returns the fresh `ObjectId`. - fn add_viewport(&mut self, spec: &crate::ops::ViewportSpec) -> ApiResult; - - /// Add a single-line `TEXT` annotation; returns the fresh `ObjectId`. - fn add_text(&mut self, spec: &crate::ops::TextSpec) -> ApiResult; - - /// Add a multi-line `MTEXT` annotation; returns the fresh `ObjectId`. - fn add_mtext(&mut self, spec: &crate::ops::MTextSpec) -> ApiResult; - - /// Set the text content of a Text/MText annotation in place. - fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()>; - - /// The text content of a Text/MText annotation. `Unsupported` for other kinds. - fn text_content(&self, id: ObjectId) -> ApiResult; - - /// Add a `HATCH` (single closed polyline boundary); returns the fresh `ObjectId`. - fn add_hatch(&mut self, spec: &crate::ops::HatchSpec) -> ApiResult; - - /// A hatch's boundary loops (outer + islands) as closed polylines. - fn hatch_boundary(&self, id: ObjectId) -> ApiResult>>; - - /// Add a linear `DIMENSION`; returns the fresh `ObjectId`. - fn add_dimension_linear(&mut self, spec: &crate::ops::DimensionSpec) -> ApiResult; - - /// Add a radial `DIMENSION`; returns the fresh `ObjectId`. - fn add_dimension_radius( - &mut self, - spec: &crate::ops::DimensionRadialSpec, - ) -> ApiResult; - - /// Add a diameter `DIMENSION`; returns the fresh `ObjectId`. - fn add_dimension_diameter( - &mut self, - spec: &crate::ops::DimensionRadialSpec, - ) -> ApiResult; - - /// Add a 3-point angular `DIMENSION`; returns the fresh `ObjectId`. - fn add_dimension_angular( - &mut self, - spec: &crate::ops::DimensionAngularSpec, - ) -> ApiResult; - - /// Add an `ATTDEF` (in-block attribute definition); returns the fresh `ObjectId`. - fn add_attribute_definition( - &mut self, - spec: &crate::ops::AttributeDefinitionSpec, - ) -> ApiResult; - - /// Add a `TABLE` (rows × columns grid); returns the fresh `ObjectId`. - fn add_table(&mut self, spec: &crate::ops::TableSpec) -> ApiResult; - - /// Add a 2-line angular `DIMENSION`; returns the fresh `ObjectId`. - fn add_dimension_angular2ln( - &mut self, - spec: &crate::ops::DimensionAngularSpec, - ) -> ApiResult; - - /// A dimension's measured value (distance for linear/radius, degrees for angular). - fn dimension_measurement(&self, id: ObjectId) -> ApiResult; - - /// Set an attribute `value` for `tag` on an insert (adds if absent). Insert-only. - fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()>; - - /// An insert's attributes as (tag, value) pairs. Insert-only. - fn attributes(&self, id: ObjectId) -> ApiResult>; - - /// The entities inside a block definition (read-only traversal), as views. - fn block_entities(&self, block_name: &str) -> ApiResult>; - - /// Set a viewport's view target + zoom height in place. Viewport-only. - fn set_viewport_view( - &mut self, - id: ObjectId, - view_target: [f64; 3], - view_height: f64, - ) -> ApiResult<()>; - - /// A viewport's view (target WCS + zoom height). Viewport-only. - fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)>; - - /// Add a `RASTER_IMAGE` (host auto-registers the ImageDefinition). - fn add_raster_image(&mut self, spec: &crate::ops::RasterImageSpec) -> ApiResult; - - /// Loft a solid through >= 2 profile curve sets (each already resolved to - /// `geom2d::Curve`s on the XY plane). Returns the fresh `ObjectId`. - fn loft( - &mut self, - sections: &[(cadkernel::space::Plane, Vec)], - ) -> ApiResult; - - /// Attach or replace an XDATA record for `application_name` on `id`. - /// `None` removes any existing record for that application. - fn set_xdata( - &mut self, - id: ObjectId, - application_name: &str, - record: Option<&crate::ops::XDataRecord>, - ) -> ApiResult<()>; - - /// Read the XDATA record for `application_name` on `id` (`None` if absent). - fn xdata( - &self, - id: ObjectId, - application_name: &str, - ) -> ApiResult>; - - /// Create a standalone `XRECORD` object from a spec; returns the fresh `ObjectId`. - fn add_xrecord(&mut self, spec: &crate::ops::XRecordSpec) -> ApiResult; - - /// Replace an existing `XRECORD` object's payload in place. - fn set_xrecord(&mut self, id: ObjectId, spec: &crate::ops::XRecordSpec) -> ApiResult<()>; - - /// Read an existing `XRECORD` object's payload (`None` if `id` is not an XRecord). - fn xrecord(&self, id: ObjectId) -> ApiResult>; - - // ── layer CRUD ───────────────────────────────────────────────────────── - /// Create a new layer. Fails with `Validation` if the name already exists or is reserved. - fn create_layer(&mut self, info: &crate::ops::LayerInfo) -> ApiResult<()>; - - /// Update an existing layer's properties by name. Fails with `Validation` if absent. - fn update_layer(&mut self, name: &str, info: &crate::ops::LayerInfo) -> ApiResult<()>; - - /// Delete a layer by name. Fails with `Validation` if the layer is "0", - /// the current layer, does not exist, or has entities. - fn delete_layer(&mut self, name: &str) -> ApiResult<()>; - - /// Look up a layer by raw name, normalizing it. Default implementation scans - /// `layers()`; backends with O(1) lookup should override. - fn lookup_layer(&self, name: &str) -> ApiResult { - let norm = crate::layer::normalize_layer_name(name); - if norm.is_empty() { - return Err(ApiError::validation("LookupLayer", "empty layer name")); - } - self.layers()? - .into_iter() - .find(|l| l.normalized_name() == norm) - .ok_or_else(|| { - ApiError::validation( - "LookupLayer", - format!("layer '{name}' does not exist"), - ) - }) - } - - /// Move an entity to a different layer. The target layer must exist. - fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()>; - - /// List all layers in the document. - fn layers(&self) -> ApiResult>; - - /// The layer name of an entity. - fn entity_layer(&self, id: ObjectId) -> ApiResult; - - /// Enumerate first-class entities, optionally filtered by kind and/or layer. - /// `include_bounds` controls whether `EntityView::bounds` is populated; skip it - /// for lightweight list views. - fn enumerate_entities( - &self, - kind: Option<&str>, - layer: Option<&str>, - include_bounds: bool, - ) -> ApiResult>; - - /// The WCS location of a Point entity. - fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]>; - - /// The start and end WCS points of a Line entity. - fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])>; - - /// Can `id` be modified in place right now (exists, is the expected family, - /// not on a locked layer)? Read-only pre-check used before mutations. - /// Default: existence + not-locked (backends narrow the family check). - fn can_modify(&self, id: ObjectId) -> ApiResult<()> { - if self.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - - /// Remove any first-level entity by id. Returns `false` if absent; returns - /// `Err` if the entity exists but cannot be removed (e.g. locked layer). - fn remove_entity(&mut self, id: ObjectId) -> ApiResult; - - /// Can `id` be removed right now (exists and is not blocked, e.g. by a locked - /// layer)? Read-only; used by the boolean-erase path to validate BEFORE any - /// mutation so a mid-op failure cannot leave partial state. Default: existence-only. - fn can_remove(&self, id: ObjectId) -> ApiResult<()> { - if self.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - - /// Whether an object (named-object / XRecord / dictionary) exists. Distinct - /// from `entity_exists`, which is for first-class entities only. - fn object_exists(&self, id: ObjectId) -> bool; - - /// A generic, untyped view of any entity (id + kind + coarse bounds). - fn get_entity(&mut self, id: ObjectId) -> ApiResult; - - /// The 2D curve geometry of a profile entity (polyline/closed curve) for - /// sweep ops (`Extrude`/`Revolve`). `UnknownId`/`Unsupported` if not a profile. - fn profile_curves(&self, id: ObjectId) -> ApiResult>; - - fn profile_plane(&self, _id: ObjectId) -> ApiResult { - Ok(cadkernel::space::Plane::XY) - } - - /// Apply a rigid similarity to any entity in place (same `ObjectId`). - fn transform_entity( - &mut self, - id: ObjectId, - placement: &crate::ops::PlacementSpec, - ) -> ApiResult<()>; - - /// Validate that `id` exists and can be transformed, WITHOUT mutating. - /// Used by `TransformMany` to pre-validate all ids before `push_undo` so the - /// apply loop cannot fail part-way (all-or-nothing, the bulk contract). Default: - /// existence-only (backends that restrict transform to a family override). - fn ensure_transformable(&mut self, id: ObjectId) -> ApiResult<()> { - if self.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - - /// Insert a vertex into a polyline at index `at` (polyline family only). - fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()>; - - // ── queries (no undo, no bump; `&mut` because solid resolution may - // populate the kernel body cache on miss) ───────────────────────────── - fn bounds(&mut self, id: ObjectId) -> ApiResult; - fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]>; - fn volume(&mut self, id: ObjectId) -> ApiResult; - fn entity_exists(&self, id: ObjectId) -> bool; - fn revision(&self) -> GeometryRevision; - - // ── per-op boundary ──────────────────────────────────────────────────── - /// Called BEFORE applying a write op (records the before-state undo snapshot). - fn push_undo(&mut self, label: &str); - /// Called AFTER a successful write op (one DeltaSnapshot + one geometry bump - /// + one publish). NOT called on failure (a failed op records nothing). - fn finalize_op(&mut self); - /// Discard an undo capture after preparation or validation failed. - fn cancel_op(&mut self) {} -} diff --git a/crates/ocs_doc_api/src/convert.rs b/crates/ocs_doc_api/src/convert.rs deleted file mode 100644 index db1f0a22f..000000000 --- a/crates/ocs_doc_api/src/convert.rs +++ /dev/null @@ -1,271 +0,0 @@ -//! Entity conversions: map the crate's plain-data DTOs to acadrust -//! `EntityType` and back. Host feature only (uses the real acadrust types). This -//! module is host-side-capable but host-independent — it operates purely on -//! acadrust entities and crate DTOs, so any host can reuse it. - -use acadrust::entities::{ - Arc, Circle, Ellipse, Line, LwPolyline, LwVertex, Point, Ray, Spline, XLine, -}; -use acadrust::types::{Vector2, Vector3}; -use acadrust::EntityType; - -use crate::{Aabb, ApiError, ApiResult, Curve2Spec, GeometryErrorKind, ObjectId}; - -/// Convert a profile entity (Line/Circle/Arc/LwPolyline) to `geom2d::Curve`s for -/// sweep ops (`Extrude`/`Revolve`). Z is dropped (profiles lie on the XY plane). -pub fn entity_to_profile_curves(entity: &EntityType) -> ApiResult> { - use cadkernel::geom2d::{Arc as KArc, Circle as KCircle, Curve as KCurve, Line as KLine}; - let curves = match entity { - EntityType::Line(l) => vec![KCurve::Line(KLine { - start: [l.start.x, l.start.y], - end: [l.end.x, l.end.y], - })], - EntityType::Circle(c) => vec![KCurve::Circle(KCircle { - centre: [c.center.x, c.center.y], - radius: c.radius, - })], - EntityType::Arc(a) => vec![KCurve::Arc(KArc { - centre: [a.center.x, a.center.y], - radius: a.radius, - start_angle: a.start_angle, - end_angle: a.end_angle, - })], - EntityType::LwPolyline(pl) => { - if pl.vertices.len() < 2 { - return Err(ApiError::validation( - "profile", - "polyline profile needs at least two vertices", - )); - } - KCurve::Polyline(cadkernel::geom2d::Polyline { - vertices: pl - .vertices - .iter() - .map(|v| cadkernel::geom2d::PolylineVertex { - position: [v.location.x, v.location.y], - bulge: v.bulge, - }) - .collect(), - closed: pl.is_closed, - }) - .segments() - } - _ => { - return Err(ApiError::Unsupported( - "profile conversion is supported for Line/Circle/Arc/LwPolyline only".into(), - )) - } - }; - Ok(curves) -} - -/// Apply a rigid similarity (rotation+translation+uniform scale) to a non-solid -/// entity in place. Supports the common 2D families; `Solid3D` goes through the -/// kernel path instead. Returns the transformed entity (same handle) for the -/// caller to `update_entity`. -pub fn transform_entity_geometry( - entity: &EntityType, - p: &crate::PlacementSpec, -) -> ApiResult { - crate::geom::validate_placement(p)?; - let matrix = acadrust::types::Matrix4 { - m: [ - [p.x_axis[0], p.y_axis[0], p.z_axis[0], p.origin[0]], - [p.x_axis[1], p.y_axis[1], p.z_axis[1], p.origin[1]], - [p.x_axis[2], p.y_axis[2], p.z_axis[2], p.origin[2]], - [0.0, 0.0, 0.0, 1.0], - ], - }; - let mut moved = entity.clone(); - moved.apply_transform(&acadrust::types::Transform::from_matrix(matrix)); - Ok(moved) -} - -fn v3(p: [f64; 3]) -> Vector3 { - Vector3::new(p[0], p[1], p[2]) -} - -/// Convert a polyline bulge segment (start → end, bulge = tan(θ/4)) to a -/// `geom2d::Curve::Arc`. Positive bulge = counter-clockwise arc; negative = clockwise. -pub fn bulge_arc_segment( - start: [f64; 2], - end: [f64; 2], - bulge: f64, -) -> ApiResult { - use cadkernel::geom2d::{Arc as KArc, Curve as KCurve}; - let arc = cadkernel::geom2d::BulgeArc::from_bulge(start, end, bulge) - .ok_or_else(|| ApiError::validation("profile", "invalid bulge segment"))?; - // The kernel's Arc is counter-clockwise; reverse clockwise endpoints. - let (start_angle, end_angle) = if arc.sweep < 0.0 { - (arc.end_angle, arc.start_angle) - } else { - (arc.start_angle, arc.end_angle) - }; - Ok(KCurve::Arc(KArc { - centre: arc.center, - radius: arc.radius, - start_angle, - end_angle, - })) -} - -/// Build an acadrust entity for a 2D curve construction spec. -pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { - crate::validation::curve(spec)?; - let entity = match spec { - Curve2Spec::Line { start, end, .. } => { - let mut e = Line::new(); - e.start = v3(*start); - e.end = v3(*end); - EntityType::Line(e) - } - Curve2Spec::Circle { centre, radius, .. } => { - let mut e = Circle::new(); - e.center = v3(*centre); - e.radius = *radius; - EntityType::Circle(e) - } - Curve2Spec::Polyline { points, closed, .. } => { - let mut e = LwPolyline::new(); - e.vertices = points - .iter() - .map(|p| LwVertex { - location: Vector2::new(p[0], p[1]), - bulge: 0.0, - start_width: 0.0, - end_width: 0.0, - vertex_id: 0, - }) - .collect(); - e.elevation = points.first().map_or(0.0, |p| p[2]); - if points.iter().any(|p| p[2] != e.elevation) { - return Err(ApiError::validation( - "CreateCurve", - "polyline points must share an elevation", - )); - } - e.is_closed = *closed; - EntityType::LwPolyline(e) - } - Curve2Spec::Point { position, .. } => { - let mut e = Point::new(); - e.location = v3(*position); - EntityType::Point(e) - } - Curve2Spec::Arc { - centre, - radius, - start_angle, - end_angle, - .. - } => { - let mut e = Arc::new(); - e.center = v3(*centre); - e.radius = *radius; - e.start_angle = *start_angle; - e.end_angle = *end_angle; - EntityType::Arc(e) - } - Curve2Spec::Ellipse { - centre, - major_axis, - ratio, - start, - end, - .. - } => { - // Validate the minor/major ratio so the (major-axis) bounds never - // under-bound: ratio must be in (0, 1]. - if !(*ratio > 0.0 && *ratio <= 1.0) { - return Err(ApiError::validation( - "CreateCurve", - format!("ellipse minor_axis_ratio must be in (0, 1], got {ratio}"), - )); - } - let mut e = Ellipse::new(); - e.center = v3(*centre); - e.major_axis = v3(*major_axis); - e.minor_axis_ratio = *ratio; - e.start_parameter = *start; - e.end_parameter = *end; - EntityType::Ellipse(e) - } - Curve2Spec::Spline { - degree, - control_points, - knots, - weights, - .. - } => { - let mut e = Spline::new(); - e.degree = *degree; - e.control_points = control_points.iter().map(|p| v3(*p)).collect(); - e.knots = knots.clone(); - e.weights = weights.clone(); - EntityType::Spline(e) - } - Curve2Spec::Ray { origin, direction, .. } => { - EntityType::Ray(Ray::new(v3(*origin), v3(*direction))) - } - Curve2Spec::XLine { origin, direction, .. } => { - EntityType::XLine(XLine::new(v3(*origin), v3(*direction))) - } - }; - Ok(entity) -} - -/// The acadrust `EntityType` variant name (for `EntityView::kind` / downcasts). -pub fn entity_kind_name(entity: &EntityType) -> &'static str { - match entity { - EntityType::Solid3D(_) => "Solid3D", - EntityType::Line(_) => "Line", - EntityType::Circle(_) => "Circle", - EntityType::LwPolyline(_) => "LwPolyline", - EntityType::Polyline(_) => "Polyline", - EntityType::Point(_) => "Point", - EntityType::Arc(_) => "Arc", - EntityType::Ellipse(_) => "Ellipse", - EntityType::Spline(_) => "Spline", - EntityType::Ray(_) => "Ray", - EntityType::XLine(_) => "XLine", - EntityType::Insert(_) => "Insert", - EntityType::Viewport(_) => "Viewport", - EntityType::Text(_) => "Text", - EntityType::MText(_) => "MText", - EntityType::AttributeDefinition(_) => "AttributeDefinition", - EntityType::AttributeEntity(_) => "AttributeEntity", - // Media & misc (phase 5, read-mostly): named so GetEntity reports a real kind. - EntityType::RasterImage(_) => "RasterImage", - EntityType::Table(_) => "Table", - EntityType::Leader(_) => "Leader", - EntityType::MultiLeader(_) => "MultiLeader", - EntityType::MLine(_) => "MLine", - EntityType::Mesh(_) => "Mesh", - EntityType::Helix(_) => "Helix", - EntityType::Region(_) => "Region", - EntityType::Body(_) => "Body", - EntityType::Surface(_) => "Surface", - EntityType::Face3D(_) => "Face3D", - EntityType::Dimension(_) => "Dimension", - EntityType::Hatch(_) => "Hatch", - _ => "Other", - } -} - -/// Existing entity bounds; unbounded or empty geometry returns an error. -pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { - let entity = entity.ok_or(ApiError::UnknownId(id))?; - if matches!(entity, EntityType::Ray(_) | EntityType::XLine(_)) { - return Err(ApiError::Unsupported("unbounded entity".into())); - } - let bounds = entity.as_entity().bounding_box(); - let min = [bounds.min.x, bounds.min.y, bounds.min.z]; - let max = [bounds.max.x, bounds.max.y, bounds.max.z]; - if min.iter().chain(&max).any(|v| !v.is_finite()) { - return Err(ApiError::geometry( - GeometryErrorKind::Empty, - "entity has no finite bounds", - )); - } - Ok(Aabb { min, max }) -} diff --git a/crates/ocs_doc_api/src/envelope.rs b/crates/ocs_doc_api/src/envelope.rs deleted file mode 100644 index 0f847740f..000000000 --- a/crates/ocs_doc_api/src/envelope.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! The wire envelope. One request = ONE write op OR a query batch -//! (read-only batching is safe: no mutation, no undo, no rollback). - -use serde::{Deserialize, Serialize}; - -use crate::gen::{Operation, Query}; -use crate::id::ObjectId; -use crate::query::QueryResult; -use crate::revision::GeometryRevision; -use crate::ENVELOPE_VERSION; - -/// The single request unit crossing the transport. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DocApiEnvelope { - /// Crate protocol version (`ENVELOPE_VERSION`). Bridges must check this. - pub version: u16, - pub body: EnvelopeBody, -} - -impl DocApiEnvelope { - pub fn validate_version(&self) -> crate::ApiResult<()> { - if self.version != ENVELOPE_VERSION { - return Err(crate::ApiError::Unsupported(format!( - "envelope version {}", - self.version - ))); - } - Ok(()) - } - - pub fn op(operation: Operation) -> Self { - Self { - version: ENVELOPE_VERSION, - body: EnvelopeBody::Op(operation), - } - } - pub fn queries(queries: Vec) -> Self { - Self { - version: ENVELOPE_VERSION, - body: EnvelopeBody::Queries(queries), - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum EnvelopeBody { - /// Exactly ONE write op (write batching removed, plan §13). Atomic by itself. - Op(Operation), - /// One-or-more read-only queries answered together (safe: no mutation/undo). - Queries(Vec), -} - -/// The outcome of an applied write op. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum OpOutcome { - /// A new entity was created; carries its fresh `ObjectId`. - NewId(ObjectId), - /// An existing entity was updated in place (same `ObjectId` preserved). - Updated(ObjectId), - /// Entities were deleted; carries the deleted ids (one for `Delete`, N for `DeleteMany`). - Deleted(Vec), - /// Many entities were created (bulk `CreateMany`); carries all fresh ids in order. - NewIds(Vec), -} - -impl OpOutcome { - /// The single fresh id, for ops that create exactly one entity. - pub fn new_id(&self) -> Option { - match self { - OpOutcome::NewId(id) => Some(*id), - _ => None, - } - } - /// All fresh ids, for bulk creates. - pub fn new_ids(&self) -> &[ObjectId] { - match self { - OpOutcome::NewIds(ids) => ids, - OpOutcome::NewId(id) => std::slice::from_ref(id), - _ => &[], - } - } -} - -/// The per-request result (and the query-batch answer carrier). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Receipt { - /// Present for `EnvelopeBody::Op`; absent for pure queries. - pub outcome: Option, - /// One `QueryResult` per `Query` in a `Queries` batch (in order). - pub query_results: Vec, - /// The geometry revision after this request (== before, for pure queries). - pub new_revision: GeometryRevision, -} diff --git a/crates/ocs_doc_api/src/error.rs b/crates/ocs_doc_api/src/error.rs deleted file mode 100644 index 783ec1434..000000000 --- a/crates/ocs_doc_api/src/error.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! Structured error model. Serializable so IPC returns the -//! same error the in-process executor produced. - -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use crate::id::ObjectId; - -/// Which kind of kernel failure occurred (a mapped `cadkernel` `Snag`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum GeometryErrorKind { - /// The operation produced an empty / degenerate body. - Empty, - /// Boolean refused (coincident geometry, unsupported cut, …). - BooleanFailed, - /// Invalid input to a kernel constructor (e.g. non-positive radius). - InvalidInput, - /// ACIS lift/parse/lower failed. - Acis, - /// Any other kernel error (message carries detail). - Other, -} - -/// The single error type for the whole API. `commit`/ops return -/// `Result<_, ApiError>`; on a failed op nothing is applied and no undo step is -/// recorded (per-op atomicity). -#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum ApiError { - /// Input failed validation before any mutation (names the op + reason). - #[error("validation failed on {op}: {reason}")] - Validation { op: String, reason: String }, - - /// The geometry kernel reported a failure (a mapped `Snag`). - #[error("geometry kernel: {kind:?}: {msg}")] - Geometry { - kind: GeometryErrorKind, - msg: String, - }, - - /// An `ObjectId` does not resolve to a live entity (stale/deleted/never existed). - #[error("unknown ObjectId {0:?}")] - UnknownId(ObjectId), - - /// The requested capability is not supported (e.g. over-cap bulk op, unimplemented family). - #[error("unsupported capability: {0}")] - Unsupported(String), - - /// The transport (IPC / channel) failed — disconnected, oversized, timeout. - #[error("transport: {0}")] - Transport(String), -} - -pub type ApiResult = Result; - -impl ApiError { - pub fn validation(op: &'static str, reason: impl Into) -> Self { - ApiError::Validation { - op: op.to_string(), - reason: reason.into(), - } - } - pub fn geometry(kind: GeometryErrorKind, msg: impl Into) -> Self { - ApiError::Geometry { - kind, - msg: msg.into(), - } - } -} diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs deleted file mode 100644 index 423fc0658..000000000 --- a/crates/ocs_doc_api/src/executor.rs +++ /dev/null @@ -1,616 +0,0 @@ -//! The op executor: crate-owned logic mapping each -//! `Operation`/`Query` to `DocApiBackend` calls. Both the in-process transport -//! and the host's IPC executor use this — one implementation, versioned in the -//! crate. Per-op atomicity: a write op validates its inputs and computes its -//! geometry BEFORE calling `push_undo`, mutates, then `finalize_op` once. - -use crate::backend::DocApiBackend; -use crate::envelope::{OpOutcome, Receipt}; -use crate::error::{ApiError, ApiResult}; -use crate::gen::{Operation, Query}; -use crate::id::ObjectId; -use crate::ops::{BoolOp, EntitySpec, BULK_ITEM_CAP}; -use crate::query::QueryResult; - -/// Apply ONE write op atomically. On success: one undo step and an advanced revision. -/// On failure: nothing applied, no undo, no bump (the op validates before mutating). -pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { - let result = apply_op_inner(b, op); - if result.is_err() { - b.cancel_op(); - } - result -} - -fn apply_op_inner(b: &mut B, op: Operation) -> ApiResult { - let name = op.op_name(); - crate::validation::operation(&op)?; - match &op { - Operation::TransformMany { ids, .. } | Operation::DeleteMany(ids) => { - let unique: std::collections::HashSet<_> = ids.iter().collect(); - if ids.is_empty() || unique.len() != ids.len() { - return Err(ApiError::validation( - name, - "batch ids must be nonempty and unique", - )); - } - } - Operation::SetXDataMany(records) => { - if records.is_empty() { - return Err(ApiError::validation( - name, - "SetXDataMany records must be nonempty", - )); - } - if records.len() > BULK_ITEM_CAP { - return Err(over_cap(name, records.len())); - } - let unique: std::collections::HashSet<_> = records.iter().map(|(id, _, _)| id).collect(); - if unique.len() != records.len() { - return Err(ApiError::validation( - name, - "SetXDataMany records must have unique entity ids", - )); - } - } - _ => {} - } - if let Operation::Transform { placement, .. } | Operation::TransformMany { placement, .. } = &op - { - crate::geom::validate_placement(placement)?; - } - let outcome = match &op { - Operation::CreateSolid(prim) => { - let body = crate::geom::make_solid(prim)?; - b.push_undo(name); - let id = b.store_solid(&body)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateCurve(spec) => { - b.push_undo(name); - let id = b.add_curve(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::Extrude { profile, direction } => { - let curves = profile_curves(b, *profile, name)?; - let body = crate::geom::extrude(b.profile_plane(*profile)?, &curves, *direction)?; - b.push_undo(name); - let id = b.store_solid(&body)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::Revolve { - profile, - axis, - angle, - } => { - let curves = profile_curves(b, *profile, name)?; - let (pivot, axis_dir) = *axis; - let body = - crate::geom::revolve(b.profile_plane(*profile)?, &curves, pivot, axis_dir, *angle)?; - b.push_undo(name); - let id = b.store_solid(&body)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::Transform { id, placement } => { - require_exists(b, *id, name)?; - b.push_undo(name); - b.transform_entity(*id, placement)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::SolidBoolean { - op: bop, - a, - b: bid, - erase_sources, - } => { - // Resolve + combine BEFORE push_undo: pure computation, no mutation. - let ba = b.resolve_body(*a).map_err(|_| ApiError::UnknownId(*a))?; - let bb = b - .resolve_body(*bid) - .map_err(|_| ApiError::UnknownId(*bid))?; - let result = crate::geom::boolean(&ba, &bb, *bop)?; - // Validate the erase BEFORE mutating: if `b` cannot be removed (e.g. - // locked layer), fail now rather than after `update_solid(a)` has - // already committed a partial mutation. - if *erase_sources { - if a == bid { - return Err(ApiError::validation(name, "boolean source ids must differ")); - } - b.can_modify(*a)?; - b.can_remove(*bid)?; - } - b.push_undo(name); - // `erase_sources`: the result lives at `a` (updated in place), `b` erased. - // Otherwise store a fresh solid and leave both inputs untouched. - let out_id = if *erase_sources { - b.update_solid(*a, &result)?; - b.remove_entity(*bid)?; - *a - } else { - b.store_solid(&result)? - }; - b.finalize_op(); - OpOutcome::NewId(out_id) - } - Operation::AddVertex { id, at, point } => { - require_exists(b, *id, name)?; - b.push_undo(name); - b.add_vertex(*id, *at, *point)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::Delete { id } => { - // can_remove validates existence AND removability (locked layer) before - // any mutation, so a locked entity errors cleanly instead of a no-op - // reported as Deleted. - b.can_remove(*id).map_err(|e| match e { - ApiError::UnknownId(_) => { - ApiError::validation(name, format!("unknown ObjectId {id:?}")) - } - other => other, - })?; - b.push_undo(name); - b.remove_entity(*id)?; - b.finalize_op(); - OpOutcome::Deleted(vec![*id]) - } - Operation::CreateMany(specs) => { - if specs.len() > BULK_ITEM_CAP { - return Err(over_cap(name, specs.len())); - } - if specs.is_empty() { - return Err(ApiError::validation(name, "batch must not be empty")); - } - for spec in specs { - if let EntitySpec::Curve(curve) = spec { - crate::validation::curve(curve)?; - } - } - let ids = b.create_many(specs)?; - OpOutcome::NewIds(ids) - } - Operation::TransformMany { ids, placement } => { - if ids.len() > BULK_ITEM_CAP { - return Err(over_cap(name, ids.len())); - } - // Pre-validate EVERYTHING fallible before push_undo (all-or-nothing, - // the bulk contract): every id must exist AND be transformable, so the apply - // loop below cannot fail part-way. `transform_entity` after this is - // infallible-by-construction for the validated ids. - for (i, id) in ids.iter().enumerate() { - b.ensure_transformable(*id).map_err(|e| match e { - ApiError::UnknownId(_) => stale_index(name, i, *id), - other => match other { - ApiError::Unsupported(msg) => ApiError::Validation { - op: name.to_string(), - reason: format!("index {i}: {msg}"), - }, - o => o, - }, - })?; - } - b.transform_many(ids, placement)?; - OpOutcome::Updated(*ids.first().unwrap_or(&ObjectId::NULL)) - } - Operation::DeleteMany(ids) => { - if ids.len() > BULK_ITEM_CAP { - return Err(over_cap(name, ids.len())); - } - // Pre-validate existence AND removability (locked layer) for every id - // before push_undo, so the apply loop cannot fail part-way. - for (i, id) in ids.iter().enumerate() { - b.can_remove(*id).map_err(|e| match e { - ApiError::UnknownId(_) => stale_index(name, i, *id), - ApiError::Unsupported(msg) => ApiError::Validation { - op: name.to_string(), - reason: format!("index {i}: {msg}"), - }, - other => other, - })?; - } - b.push_undo(name); - for id in ids { - b.remove_entity(*id)?; - } - b.finalize_op(); - OpOutcome::Deleted(ids.clone()) - } - Operation::CreateInsert(spec) => { - // Validate the block name exists before committing (no rollback needed). - b.push_undo(name); - let id = b.add_insert(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateViewport(spec) => { - b.push_undo(name); - let id = b.add_viewport(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateText(spec) => { - b.push_undo(name); - let id = b.add_text(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateMText(spec) => { - b.push_undo(name); - let id = b.add_mtext(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::SetTextContent { id, value } => { - b.can_modify(*id).map_err(|e| match e { - ApiError::UnknownId(_) => { - ApiError::validation(name, format!("unknown ObjectId {id:?}")) - } - other => other, - })?; - b.push_undo(name); - b.set_text_content(*id, value)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::CreateHatch(spec) => { - // Validate the boundary (>= 3 points) before committing. - if spec.boundary.len() < 3 { - return Err(ApiError::validation( - name, - "hatch boundary needs >= 3 points", - )); - } - b.push_undo(name); - let id = b.add_hatch(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionLinear(spec) => { - b.push_undo(name); - let id = b.add_dimension_linear(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::SetAttribute { id, tag, value } => { - b.can_modify(*id).map_err(|e| match e { - ApiError::UnknownId(_) => { - ApiError::validation(name, format!("unknown ObjectId {id:?}")) - } - other => other, - })?; - b.push_undo(name); - b.set_attribute(*id, tag, value)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::SetViewportView { - id, - view_target, - view_height, - } => { - b.can_modify(*id).map_err(|e| match e { - ApiError::UnknownId(_) => { - ApiError::validation(name, format!("unknown ObjectId {id:?}")) - } - other => other, - })?; - b.push_undo(name); - b.set_viewport_view(*id, *view_target, *view_height)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::CreateRasterImage(spec) => { - b.push_undo(name); - let id = b.add_raster_image(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::Loft { profiles } => { - // Bound the section count like the bulk ops (one envelope can carry many - // ids; an uncapped loft would be an unbounded kernel call on the UI thread). - if profiles.len() > BULK_ITEM_CAP { - return Err(over_cap(name, profiles.len())); - } - // Resolve every profile to its curve set BEFORE push_undo (all-or-nothing). - if profiles.len() < 2 { - return Err(ApiError::validation(name, "loft needs >= 2 profiles")); - } - let mut sections = Vec::with_capacity(profiles.len()); - for (i, p) in profiles.iter().enumerate() { - let curves = profile_curves(b, *p, name).map_err(|e| at_index(name, i, e))?; - sections.push((b.profile_plane(*p)?, curves)); - } - b.push_undo(name); - let id = b.loft(§ions)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionRadius(spec) => { - b.push_undo(name); - let id = b.add_dimension_radius(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionDiameter(spec) => { - b.push_undo(name); - let id = b.add_dimension_diameter(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionAngular(spec) => { - b.push_undo(name); - let id = b.add_dimension_angular(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateAttributeDefinition(spec) => { - b.push_undo(name); - let id = b.add_attribute_definition(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateTable(spec) => { - // Validate a non-empty, rectangular grid before committing. - let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); - if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) { - return Err(ApiError::validation( - name, - "table needs a non-empty rectangular grid", - )); - } - b.push_undo(name); - let id = b.add_table(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionAngular2Ln(spec) => { - b.push_undo(name); - let id = b.add_dimension_angular2ln(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateLayer(info) => { - b.push_undo(name); - b.create_layer(info)?; - b.finalize_op(); - OpOutcome::Updated(ObjectId::NULL) - } - Operation::UpdateLayer { name, info } => { - b.push_undo(name); - b.update_layer(name, info)?; - b.finalize_op(); - OpOutcome::Updated(ObjectId::NULL) - } - Operation::DeleteLayer { name } => { - b.push_undo(name); - b.delete_layer(name)?; - b.finalize_op(); - OpOutcome::Updated(ObjectId::NULL) - } - Operation::SetEntityLayer { id, layer } => { - require_exists(b, *id, name)?; - b.push_undo(name); - b.set_entity_layer(*id, layer)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::SetXData { - id, - application_name, - record, - } => { - require_exists(b, *id, name)?; - b.push_undo(name); - b.set_xdata(*id, application_name, record.as_ref())?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::SetXDataMany(records) => { - // A later locked entity must not leave earlier records changed. - for (i, (id, _, _)) in records.iter().enumerate() { - b.can_modify(*id).map_err(|e| match e { - ApiError::UnknownId(_) => stale_index(name, i, *id), - ApiError::Unsupported(reason) => { - ApiError::validation(name, format!("index {i}: {reason}")) - } - other => other, - })?; - } - b.push_undo(name); - for (id, application_name, record) in records { - b.set_xdata(*id, application_name, record.as_ref())?; - } - b.finalize_op(); - OpOutcome::Updated(ObjectId::NULL) - } - Operation::CreateXRecord(spec) => { - b.push_undo(name); - let id = b.add_xrecord(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::SetXRecord { id, spec } => { - require_object_exists(b, *id, name)?; - b.push_undo(name); - b.set_xrecord(*id, spec)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - }; - Ok(Receipt { - outcome: Some(outcome), - query_results: Vec::new(), - new_revision: b.revision(), - }) -} - -/// Apply a batch of read-only queries (no undo, no bump; `&mut` because solid -/// resolution may populate the kernel body cache on miss). Bounded like the bulk -/// ops: a batch over `BULK_ITEM_CAP` is rejected before any work (the 64 MiB frame -/// cap bounds message size, not count). -pub fn apply_queries(b: &mut B, queries: Vec) -> ApiResult { - if queries.len() > BULK_ITEM_CAP { - return Err(ApiError::validation( - "Queries", - format!( - "query batch over cap: {} > {}", - queries.len(), - BULK_ITEM_CAP - ), - )); - } - let mut results = Vec::with_capacity(queries.len()); - for q in &queries { - let qname = q.query_name(); - let r = match q { - Query::GetEntity { id } => { - QueryResult::Entity(b.get_entity(*id).map_err(|e| label(qname, e))?) - } - Query::GetBounds { id } => { - QueryResult::Bounds(b.bounds(*id).map_err(|e| label(qname, e))?) - } - Query::GetCentroid { id } => { - QueryResult::Centroid(b.centroid(*id).map_err(|e| label(qname, e))?) - } - Query::GetVolume { id } => { - QueryResult::Volume(b.volume(*id).map_err(|e| label(qname, e))?) - } - Query::GetIntersects { a, b: bid } => { - let ba = b.bounds(*a).map_err(|e| label(qname, e))?; - let bb = b.bounds(*bid).map_err(|e| label(qname, e))?; - QueryResult::Intersects(ba.overlaps(&bb)) - } - Query::GetGeometryRevision => QueryResult::Revision(b.revision()), - Query::GetTextContent { id } => { - QueryResult::TextContent(b.text_content(*id).map_err(|e| label(qname, e))?) - } - Query::GetHatchBoundary { id } => { - QueryResult::HatchBoundary(b.hatch_boundary(*id).map_err(|e| label(qname, e))?) - } - Query::GetDimensionMeasurement { id } => QueryResult::DimensionMeasurement( - b.dimension_measurement(*id).map_err(|e| label(qname, e))?, - ), - Query::GetAttributes { id } => { - QueryResult::Attributes(b.attributes(*id).map_err(|e| label(qname, e))?) - } - Query::GetBlockEntities { block_name } => QueryResult::BlockEntities( - b.block_entities(block_name).map_err(|e| label(qname, e))?, - ), - Query::GetViewportView { id } => { - let (target, height) = b.viewport_view(*id).map_err(|e| label(qname, e))?; - QueryResult::ViewportView { target, height } - } - Query::GetXData { - id, - application_name, - } => QueryResult::XData( - b.xdata(*id, application_name) - .map_err(|e| label(qname, e))? - .clone(), - ), - Query::GetXRecord { id } => { - QueryResult::XRecord(b.xrecord(*id).map_err(|e| label(qname, e))?.ok_or_else( - || ApiError::validation(qname, format!("ObjectId {id:?} is not an XRecord")), - )?) - } - Query::ListLayers => QueryResult::Layers(b.layers().map_err(|e| label(qname, e))?), - Query::GetEntityLayer { id } => { - QueryResult::EntityLayer(b.entity_layer(*id).map_err(|e| label(qname, e))?) - } - Query::EnumerateEntities { - kind, - layer, - include_bounds, - } => QueryResult::Entities( - b.enumerate_entities(kind.as_deref(), layer.as_deref(), *include_bounds) - .map_err(|e| label(qname, e))?, - ), - Query::GetPointPosition { id } => QueryResult::PointPosition( - b.point_position(*id).map_err(|e| label(qname, e))?, - ), - Query::GetLineGeometry { id } => { - let (start, end) = b.line_geometry(*id).map_err(|e| label(qname, e))?; - QueryResult::LineGeometry((start, end)) - } - }; - results.push(r); - } - Ok(Receipt { - outcome: None, - query_results: results, - new_revision: b.revision(), - }) -} - -/// Attach the query name to an error for better diagnostics (uses `query_name`). -fn label(query: &'static str, e: ApiError) -> ApiError { - match e { - ApiError::Validation { reason, .. } => ApiError::validation(query, reason), - ApiError::UnknownId(id) => ApiError::validation(query, format!("unknown ObjectId {id:?}")), - other => other, - } -} - -fn profile_curves( - b: &B, - profile: ObjectId, - op: &'static str, -) -> ApiResult> { - if !b.entity_exists(profile) { - return Err(ApiError::validation( - op, - format!("unknown ObjectId {profile:?}"), - )); - } - b.profile_curves(profile) -} - -/// Validate that an entity exists before performing an operation. -fn require_exists(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> { - if b.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::validation(op, format!("unknown ObjectId {id:?}"))) - } -} - -/// Validate that a named object exists before performing an operation. -fn require_object_exists(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> { - if b.object_exists(id) { - Ok(()) - } else { - Err(ApiError::validation(op, format!("unknown ObjectId {id:?}"))) - } -} - -fn over_cap(op: &'static str, n: usize) -> ApiError { - ApiError::validation(op, format!("bulk op over cap: {n} > {BULK_ITEM_CAP}")) -} - -fn stale_index(op: &'static str, i: usize, id: ObjectId) -> ApiError { - ApiError::validation(op, format!("stale ObjectId at index {i}: {id:?}")) -} - -fn at_index(op: &'static str, i: usize, e: ApiError) -> ApiError { - match e { - ApiError::Validation { reason, .. } => { - ApiError::validation(op, format!("index {i}: {reason}")) - } - other => other, - } -} - -/// Boolean op kind → kernel `Operation` (host feature only). -#[cfg(feature = "host")] -pub(crate) fn kernel_bool_op(op: BoolOp) -> cadkernel::brep::Operation { - match op { - BoolOp::Union => cadkernel::brep::Operation::Union, - BoolOp::Intersection => cadkernel::brep::Operation::Intersection, - BoolOp::Difference => cadkernel::brep::Operation::Difference, - } -} diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs deleted file mode 100644 index ef6f2b13d..000000000 --- a/crates/ocs_doc_api/src/facade.rs +++ /dev/null @@ -1,1413 +0,0 @@ -//! The object-centric facade. -//! -//! Hierarchy: [`DocApi`] (root: session + transport) → [`Document`] → -//! collections ([`SolidCollection`], [`CurveCollection`], [`EntityCollection`]) -//! → typed handles ([`Solid`], [`Line`], [`Circle`], [`Polyline`], [`Point`], -//! [`Entity`]) carrying methods. Every handle = `{ ObjectId, Arc }` — -//! `Clone`, `Send`, `Sync`; it never holds kernel geometry. Per-op autocommit: -//! each method is ONE atomic host call = one undo step (no transactions). - -use std::sync::Arc; - -use crate::envelope::{DocApiEnvelope, Receipt}; -use crate::error::{ApiError, ApiResult}; -use crate::gen::{Operation, Query}; -use crate::id::ObjectId; -use crate::ops::{BoolOp, Curve2Spec, PlacementSpec, SolidPrimitive, XDataRecord, XRecordSpec}; -use crate::query::{Aabb, EntityView, QueryResult}; -use crate::revision::GeometryRevision; -use crate::transport::Transport; - -/// Shared session: the transport. Held by every handle. The transport already -/// carries the tab binding (IPC) or the backend (in-process), so the session -/// stores no per-tab field. -#[derive(Clone)] -pub struct Session { - transport: Arc, - valid_tab: bool, -} - -impl Session { - fn validate_tab(&self) -> ApiResult<()> { - if !self.valid_tab { - return Err(ApiError::validation( - "document", - "transport is bound to another tab", - )); - } - Ok(()) - } - fn same_transport(&self, other: &Session) -> ApiResult<()> { - self.validate_tab()?; - other.validate_tab()?; - if !Arc::ptr_eq(&self.transport, &other.transport) { - return Err(ApiError::validation( - "document", - "handles belong to different sessions", - )); - } - Ok(()) - } - fn apply_op(&self, op: Operation) -> ApiResult { - self.validate_tab()?; - self.transport.apply(DocApiEnvelope::op(op)) - } - fn apply_queries(&self, queries: Vec) -> ApiResult { - self.validate_tab()?; - self.transport.apply(DocApiEnvelope::queries(queries)) - } - fn one_query(&self, q: Query) -> ApiResult { - let mut r = self.apply_queries(vec![q])?; - let result = r.query_results.drain(..).next(); - result.ok_or_else(|| ApiError::Transport("empty query result".into())) - } -} - -/// The API root: owns the session (transport + active tab). Entry point. -#[derive(Clone)] -pub struct DocApi { - transport: Arc, - active_tab: u64, -} - -impl DocApi { - /// Connect over an existing transport (IPC or in-process) bound to `active_tab`. - pub fn connect(transport: Arc, active_tab: u64) -> Self { - Self { - transport, - active_tab, - } - } - /// Connect in-process over a backend (host feature). - #[cfg(feature = "host")] - pub fn in_process( - backend: B, - active_tab: u64, - ) -> Self { - Self::connect( - Arc::new(crate::transport::InProcess::new(backend)), - active_tab, - ) - } - - /// The active tab id this root is bound to. - pub fn active_tab(&self) -> u64 { - self.active_tab - } - - /// Liveness probe of the host/transport. - pub fn alive(&self) -> bool { - self.transport.alive() - } - - /// Bind a document to the transport's tab. Requests for another tab fail. - pub fn document(&self, tab: u64) -> Document { - Document { - session: Session { - transport: Arc::clone(&self.transport), - valid_tab: tab == self.active_tab, - }, - } - } -} - -/// One open document tab: factory + lookup + queries + read-guards. -#[derive(Clone)] -pub struct Document { - session: Session, -} - -impl Document { - pub fn solids(&self) -> SolidCollection { - SolidCollection { - session: self.session.clone(), - } - } - pub fn curves(&self) -> CurveCollection { - CurveCollection { - session: self.session.clone(), - } - } - pub fn entities(&self) -> EntityCollection { - EntityCollection { - session: self.session.clone(), - } - } - - /// Current geometry revision (query, no bump). - pub fn revision(&self) -> ApiResult { - match self.session.one_query(Query::GetGeometryRevision)? { - QueryResult::Revision(r) => Ok(r), - _ => Err(ApiError::Transport("unexpected revision result".into())), - } - } - - /// Standalone read-guard: fail with `ApiError::Validation` if the document's - /// revision moved past `expected` (read-modify-write guard; NOT a batch pre-condition). - pub fn assert_revision(&self, expected: GeometryRevision) -> ApiResult<()> { - let now = self.revision()?; - if now == expected { - Ok(()) - } else { - Err(ApiError::Validation { - op: "assert_revision".to_string(), - reason: format!( - "revision moved: expected {expected:?}, now {now:?}; re-read and retry" - ), - }) - } - } - - /// Read-only traversal of a block definition's entities (ids + kinds + bounds). - pub fn block_entities(&self, block_name: &str) -> ApiResult> { - match self.session.one_query(Query::GetBlockEntities { - block_name: block_name.to_string(), - })? { - QueryResult::BlockEntities(v) => Ok(v), - _ => Err(ApiError::Transport( - "unexpected block-entities result".into(), - )), - } - } - - /// List all layers in the document. - pub fn layers(&self) -> ApiResult> { - match self.session.one_query(Query::ListLayers)? { - QueryResult::Layers(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected layers result".into())), - } - } - - /// Create a new layer. One undo step; fails if the normalized name already - /// exists or is empty. - pub fn create_layer(&self, info: &crate::ops::LayerInfo) -> ApiResult<()> { - if !crate::layer::is_valid_layer_name(&info.name) { - return Err(ApiError::validation( - "CreateLayer", - "empty layer name", - )); - } - self.session - .apply_op(Operation::CreateLayer(info.clone()))?; - Ok(()) - } - - /// Update an existing layer's properties by name. One undo step; `info.name` - /// becomes the new display name (case-insensitive key unchanged). - pub fn update_layer(&self, name: &str, info: &crate::ops::LayerInfo) -> ApiResult<()> { - if !crate::layer::is_valid_layer_name(&info.name) { - return Err(ApiError::validation( - "UpdateLayer", - "empty layer name", - )); - } - self.session.apply_op(Operation::UpdateLayer { - name: name.to_string(), - info: info.clone(), - })?; - Ok(()) - } - - /// Delete a layer by name. One undo step; fails for layer "0", the current - /// layer, or any layer still referenced by entities. - pub fn delete_layer(&self, name: &str) -> ApiResult<()> { - self.session.apply_op(Operation::DeleteLayer { - name: name.to_string(), - })?; - Ok(()) - } - - /// Set XDATA on many entities in a single atomic operation. - /// - /// Each tuple is `(entity_id, application_name, record)`. Pass `None` for - /// `record` to delete the application's data. Fails up-front if any id is - /// invalid; the whole batch is one undo step. - pub fn set_xdata_many(&self, items: I) -> ApiResult<()> - where - I: IntoIterator)>, - S: Into, - { - let updates: Vec<_> = items - .into_iter() - .map(|(id, app, record)| (id, app.into(), record)) - .collect(); - if updates.is_empty() { - return Ok(()); - } - self.session.apply_op(Operation::SetXDataMany(updates))?; - Ok(()) - } - - /// Batch of read-only queries in ONE round-trip (safe: no mutation/undo). - /// The closure records queries on a [`QueryBatch`]; the results are returned - /// in the same order as a [`QueryResults`] view the caller destructures. - pub fn query_batch(&self, f: F) -> ApiResult { - let mut qb = QueryBatch { - queries: Vec::new(), - session: self.session.clone(), - error: None, - }; - f(&mut qb); - if let Some(error) = qb.error { - return Err(error); - } - let receipt = self.session.apply_queries(qb.queries)?; - Ok(QueryResults { - results: receipt.query_results, - }) - } -} - -/// Records queries for [`Document::query_batch`]. Each method appends one query. -pub struct QueryBatch { - queries: Vec, - session: Session, - error: Option, -} - -impl QueryBatch { - fn check(&mut self, e: &impl HasId) { - if let Err(error) = e.validate_session(&self.session) { - self.error = Some(error); - } - } - pub fn bounds(&mut self, e: &impl HasId) { - self.check(e); - self.queries.push(Query::GetBounds { id: e.id() }); - } - pub fn volume(&mut self, e: &impl HasId) { - self.check(e); - self.queries.push(Query::GetVolume { id: e.id() }); - } - pub fn centroid(&mut self, e: &impl HasId) { - self.check(e); - self.queries.push(Query::GetCentroid { id: e.id() }); - } - pub fn intersects(&mut self, a: &impl HasId, b: &impl HasId) { - self.check(a); - self.check(b); - self.queries.push(Query::GetIntersects { - a: a.id(), - b: b.id(), - }); - } - pub fn revision(&mut self) { - self.queries.push(Query::GetGeometryRevision); - } -} - -/// Ordered results of a [`Document::query_batch`], one per recorded query. -pub struct QueryResults { - results: Vec, -} - -impl QueryResults { - fn at(&self, i: usize) -> ApiResult<&QueryResult> { - self.results - .get(i) - .ok_or_else(|| ApiError::Transport("query result missing".into())) - } - pub fn bounds(&self, i: usize) -> ApiResult { - match self.at(i)? { - QueryResult::Bounds(b) => Ok(*b), - _ => Err(ApiError::Transport("result is not bounds".into())), - } - } - pub fn volume(&self, i: usize) -> ApiResult { - match self.at(i)? { - QueryResult::Volume(v) => Ok(*v), - _ => Err(ApiError::Transport("result is not volume".into())), - } - } - pub fn centroid(&self, i: usize) -> ApiResult<[f64; 3]> { - match self.at(i)? { - QueryResult::Centroid(c) => Ok(*c), - _ => Err(ApiError::Transport("result is not centroid".into())), - } - } - pub fn intersects(&self, i: usize) -> ApiResult { - match self.at(i)? { - QueryResult::Intersects(x) => Ok(*x), - _ => Err(ApiError::Transport("result is not intersects".into())), - } - } - pub fn revision(&self, i: usize) -> ApiResult { - match self.at(i)? { - QueryResult::Revision(r) => Ok(*r), - _ => Err(ApiError::Transport("result is not revision".into())), - } - } -} - -/// Something with an `ObjectId` (all handles + raw ids). -pub trait HasId { - fn id(&self) -> ObjectId; - /// Typed handles retain their session; raw ids are relative to the receiver. - fn session(&self) -> Option<&Session> { - None - } - fn validate_session(&self, target: &Session) -> ApiResult<()> { - if let Some(session) = self.session() { - session.same_transport(target)?; - } - Ok(()) - } -} -impl HasId for ObjectId { - fn id(&self) -> ObjectId { - *self - } -} - -// ── Typed handles ──────────────────────────────────────────────────────────── - -macro_rules! handle { - ($name:ident) => { - #[derive(Clone)] - pub struct $name { - session: Session, - id: ObjectId, - } - impl std::fmt::Debug for $name { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, concat!(stringify!($name), "({:?})"), self.id) - } - } - impl $name { - fn new(session: Session, id: ObjectId) -> Self { - Self { session, id } - } - } - impl HasId for $name { - fn session(&self) -> Option<&Session> { - Some(&self.session) - } - fn id(&self) -> ObjectId { - self.id - } - } - }; -} - -macro_rules! entity_handle { - ($name:ident) => { - handle!($name); - impl $name { - pub fn bounds(&self) -> ApiResult { - match self.session.one_query(Query::GetBounds { id: self.id })? { - QueryResult::Bounds(b) => Ok(b), - _ => Err(ApiError::Transport("unexpected bounds result".into())), - } - } - pub fn delete(&self) -> ApiResult<()> { - self.session.apply_op(Operation::Delete { id: self.id })?; - Ok(()) - } - pub fn transform(&self, placement: PlacementSpec) -> ApiResult<()> { - self.session.apply_op(Operation::Transform { - id: self.id, - placement, - })?; - Ok(()) - } - /// Return the entity's current layer name (normalized uppercase). - pub fn layer(&self) -> ApiResult { - match self - .session - .one_query(Query::GetEntityLayer { id: self.id })? - { - QueryResult::EntityLayer(name) => Ok(name), - _ => Err(ApiError::Transport("unexpected entity layer result".into())), - } - } - /// Move the entity to a different layer. One undo step; fails if the - /// target layer does not exist or is locked. - pub fn set_layer(&self, layer: &str) -> ApiResult<()> { - self.session.apply_op(Operation::SetEntityLayer { - id: self.id, - layer: layer.to_string(), - })?; - Ok(()) - } - } - }; -} - -entity_handle!(Entity); -entity_handle!(Solid); -entity_handle!(Line); -entity_handle!(Circle); -entity_handle!(Polyline); -entity_handle!(Point); -entity_handle!(ArcCurve); -entity_handle!(Ellipse); -entity_handle!(Spline); -entity_handle!(Ray); -entity_handle!(XLine); -entity_handle!(Text); -entity_handle!(MText); -entity_handle!(Dimension); -handle!(XRecord); - -impl XRecord { - /// Read the XRECORD payload. - pub fn payload(&self) -> ApiResult { - match self.session.one_query(Query::GetXRecord { id: self.id })? { - QueryResult::XRecord(spec) => Ok(spec), - _ => Err(ApiError::Transport("unexpected xrecord result".into())), - } - } - /// Replace the XRECORD payload in place (one undo step). - pub fn set_payload(&self, spec: &XRecordSpec) -> ApiResult<()> { - self.session.apply_op(Operation::SetXRecord { - id: self.id, - spec: spec.clone(), - })?; - Ok(()) - } -} - -impl Dimension { - /// The measured value of this dimension (distance for linear/radius, degrees - /// for angular). - pub fn measurement(&self) -> ApiResult { - match self - .session - .one_query(Query::GetDimensionMeasurement { id: self.id })? - { - QueryResult::DimensionMeasurement(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected measurement result".into())), - } - } -} - -impl Entity { - pub fn view(&self) -> ApiResult { - match self.session.one_query(Query::GetEntity { id: self.id })? { - QueryResult::Entity(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected entity result".into())), - } - } - /// This insert's attributes as (tag, value) pairs. Insert-only. - pub fn attributes(&self) -> ApiResult> { - match self - .session - .one_query(Query::GetAttributes { id: self.id })? - { - QueryResult::Attributes(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected attributes result".into())), - } - } - /// Set an attribute `value` for `tag` on this insert (adds if absent). One undo step. - pub fn set_attribute(&self, tag: &str, value: &str) -> ApiResult<()> { - self.session.apply_op(Operation::SetAttribute { - id: self.id, - tag: tag.to_string(), - value: value.to_string(), - })?; - Ok(()) - } - /// This viewport's view (target WCS + zoom height). Viewport-only. - pub fn viewport_view(&self) -> ApiResult<([f64; 3], f64)> { - match self - .session - .one_query(Query::GetViewportView { id: self.id })? - { - QueryResult::ViewportView { target, height } => Ok((target, height)), - _ => Err(ApiError::Transport( - "unexpected viewport-view result".into(), - )), - } - } - /// Retarget / re-zoom this viewport (one undo step). Viewport-only. - pub fn set_view(&self, view_target: [f64; 3], view_height: f64) -> ApiResult<()> { - self.session.apply_op(Operation::SetViewportView { - id: self.id, - view_target, - view_height, - })?; - Ok(()) - } - /// The XDATA record for `application_name` on this entity (`None` if absent). - pub fn xdata(&self, application_name: &str) -> ApiResult> { - match self.session.one_query(Query::GetXData { - id: self.id, - application_name: application_name.to_string(), - })? { - QueryResult::XData(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected xdata result".into())), - } - } - /// Attach or replace an XDATA record for `application_name` on this entity. - /// Pass `None` to remove the record (one undo step). - pub fn set_xdata(&self, application_name: &str, record: Option) -> ApiResult<()> { - self.session.apply_op(Operation::SetXData { - id: self.id, - application_name: application_name.to_string(), - record, - })?; - Ok(()) - } - pub fn as_solid(&self) -> Option { - // Typed downcast is validated by the view's kind. - matches!(self.view().ok()?.kind.as_str(), "Solid3D") - .then(|| Solid::new(self.session.clone(), self.id)) - } -} - -impl Solid { - fn boolean(&self, op: BoolOp, other: &Solid) -> ApiResult { - self.session.same_transport(&other.session)?; - let receipt = self.session.apply_op(Operation::SolidBoolean { - op, - a: self.id, - b: other.id, - erase_sources: true, - })?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("boolean returned no id".into()))?; - Ok(Solid::new(self.session.clone(), id)) - } - pub fn intersect(&self, other: &Solid) -> ApiResult { - self.boolean(BoolOp::Intersection, other) - } - pub fn union(&self, other: &Solid) -> ApiResult { - self.boolean(BoolOp::Union, other) - } - pub fn subtract(&self, other: &Solid) -> ApiResult { - self.boolean(BoolOp::Difference, other) - } - pub fn volume(&self) -> ApiResult { - match self.session.one_query(Query::GetVolume { id: self.id })? { - QueryResult::Volume(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected volume result".into())), - } - } - pub fn centroid(&self) -> ApiResult<[f64; 3]> { - match self.session.one_query(Query::GetCentroid { id: self.id })? { - QueryResult::Centroid(c) => Ok(c), - _ => Err(ApiError::Transport("unexpected centroid result".into())), - } - } - pub fn intersects(&self, other: &Solid) -> ApiResult { - self.session.same_transport(&other.session)?; - match self.session.one_query(Query::GetIntersects { - a: self.id, - b: other.id, - })? { - QueryResult::Intersects(x) => Ok(x), - _ => Err(ApiError::Transport("unexpected intersects result".into())), - } - } -} - -impl Polyline { - pub fn add_vertex(&self, at: usize, point: [f64; 3]) -> ApiResult<()> { - self.session.apply_op(Operation::AddVertex { - id: self.id, - at, - point, - })?; - Ok(()) - } -} - -macro_rules! text_handle { - ($name:ident) => { - impl $name { - /// The text content of this annotation. - pub fn content(&self) -> ApiResult { - match self - .session - .one_query(Query::GetTextContent { id: self.id })? - { - QueryResult::TextContent(s) => Ok(s), - _ => Err(ApiError::Transport("unexpected content result".into())), - } - } - /// Replace the text content (one undo step). - pub fn set_content(&self, value: &str) -> ApiResult<()> { - self.session.apply_op(Operation::SetTextContent { - id: self.id, - value: value.to_string(), - })?; - Ok(()) - } - } - }; -} -text_handle!(Text); -text_handle!(MText); - -// ── Collections (construction / lookup) ───────────────────────────────────── - -#[derive(Clone)] -pub struct SolidCollection { - session: Session, -} - -impl SolidCollection { - fn create(&self, prim: SolidPrimitive) -> ApiResult { - let receipt = self.session.apply_op(Operation::CreateSolid(prim))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_solid returned no id".into()))?; - Ok(Solid::new(self.session.clone(), id)) - } - pub fn create_cuboid(&self, origin: [f64; 3], size: [f64; 3]) -> ApiResult { - self.create(SolidPrimitive::Cuboid { origin, size }) - } - pub fn create_sphere(&self, centre: [f64; 3], radius: f64) -> ApiResult { - self.create(SolidPrimitive::Sphere { centre, radius }) - } - pub fn create_cylinder(&self, base: [f64; 3], radius: f64, height: f64) -> ApiResult { - self.create(SolidPrimitive::Cylinder { - base, - radius, - height, - }) - } - pub fn create_cone(&self, base: [f64; 3], radius: f64, height: f64) -> ApiResult { - self.create(SolidPrimitive::Cone { - base, - radius, - height, - }) - } - pub fn create_torus( - &self, - centre: [f64; 3], - major_radius: f64, - minor_radius: f64, - ) -> ApiResult { - self.create(SolidPrimitive::Torus { - centre, - major_radius, - minor_radius, - }) - } - pub fn create_wedge(&self, origin: [f64; 3], size: [f64; 3]) -> ApiResult { - self.create(SolidPrimitive::Wedge { origin, size }) - } - pub fn extrude(&self, profile: &impl HasId, direction: [f64; 3]) -> ApiResult { - profile.validate_session(&self.session)?; - let receipt = self.session.apply_op(Operation::Extrude { - profile: profile.id(), - direction, - })?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("extrude returned no id".into()))?; - Ok(Solid::new(self.session.clone(), id)) - } - pub fn revolve( - &self, - profile: &impl HasId, - pivot: [f64; 3], - axis: [f64; 3], - angle: f64, - ) -> ApiResult { - profile.validate_session(&self.session)?; - let receipt = self.session.apply_op(Operation::Revolve { - profile: profile.id(), - axis: (pivot, axis), - angle, - })?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("revolve returned no id".into()))?; - Ok(Solid::new(self.session.clone(), id)) - } - /// Loft a solid through >= 2 profile entities (polylines/circles/arcs). - pub fn loft(&self, profiles: &[impl HasId]) -> ApiResult { - for profile in profiles { - profile.validate_session(&self.session)?; - } - let ids: Vec = profiles.iter().map(|p| p.id()).collect(); - let receipt = self.session.apply_op(Operation::Loft { profiles: ids })?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("loft returned no id".into()))?; - Ok(Solid::new(self.session.clone(), id)) - } -} - -#[derive(Clone)] -pub struct CurveCollection { - session: Session, -} - -impl CurveCollection { - fn create_curve(&self, spec: Curve2Spec) -> ApiResult<(Session, ObjectId)> { - let receipt = self.session.apply_op(Operation::CreateCurve(spec))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_curve returned no id".into()))?; - Ok((self.session.clone(), id)) - } - pub fn create_line(&self, start: [f64; 3], end: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Line { - start, - end, - layer: None, - })?; - Ok(Line::new(s, id)) - } - pub fn create_line_on_layer( - &self, - start: [f64; 3], - end: [f64; 3], - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Line { - start, - end, - layer: Some(layer.to_string()), - })?; - Ok(Line::new(s, id)) - } - pub fn create_circle(&self, centre: [f64; 3], radius: f64) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Circle { - centre, - radius, - layer: None, - })?; - Ok(Circle::new(s, id)) - } - pub fn create_circle_on_layer( - &self, - centre: [f64; 3], - radius: f64, - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Circle { - centre, - radius, - layer: Some(layer.to_string()), - })?; - Ok(Circle::new(s, id)) - } - pub fn create_polyline(&self, points: &[[f64; 3]], closed: bool) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Polyline { - points: points.to_vec(), - closed, - layer: None, - })?; - Ok(Polyline::new(s, id)) - } - pub fn create_polyline_on_layer( - &self, - points: &[[f64; 3]], - closed: bool, - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Polyline { - points: points.to_vec(), - closed, - layer: Some(layer.to_string()), - })?; - Ok(Polyline::new(s, id)) - } - pub fn create_point(&self, position: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Point { - position, - layer: None, - })?; - Ok(Point::new(s, id)) - } - pub fn create_point_on_layer(&self, position: [f64; 3], layer: &str) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Point { - position, - layer: Some(layer.to_string()), - })?; - Ok(Point::new(s, id)) - } - pub fn create_arc( - &self, - centre: [f64; 3], - radius: f64, - start_angle: f64, - end_angle: f64, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Arc { - centre, - radius, - start_angle, - end_angle, - layer: None, - })?; - Ok(ArcCurve::new(s, id)) - } - pub fn create_arc_on_layer( - &self, - centre: [f64; 3], - radius: f64, - start_angle: f64, - end_angle: f64, - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Arc { - centre, - radius, - start_angle, - end_angle, - layer: Some(layer.to_string()), - })?; - Ok(ArcCurve::new(s, id)) - } - pub fn create_ellipse( - &self, - centre: [f64; 3], - major_axis: [f64; 3], - ratio: f64, - start: f64, - end: f64, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Ellipse { - centre, - major_axis, - ratio, - start, - end, - layer: None, - })?; - Ok(Ellipse::new(s, id)) - } - pub fn create_ellipse_on_layer( - &self, - centre: [f64; 3], - major_axis: [f64; 3], - ratio: f64, - start: f64, - end: f64, - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Ellipse { - centre, - major_axis, - ratio, - start, - end, - layer: Some(layer.to_string()), - })?; - Ok(Ellipse::new(s, id)) - } - pub fn create_spline( - &self, - degree: i32, - control_points: &[[f64; 3]], - knots: &[f64], - weights: &[f64], - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Spline { - degree, - control_points: control_points.to_vec(), - knots: knots.to_vec(), - weights: weights.to_vec(), - layer: None, - })?; - Ok(Spline::new(s, id)) - } - pub fn create_spline_on_layer( - &self, - degree: i32, - control_points: &[[f64; 3]], - knots: &[f64], - weights: &[f64], - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Spline { - degree, - control_points: control_points.to_vec(), - knots: knots.to_vec(), - weights: weights.to_vec(), - layer: Some(layer.to_string()), - })?; - Ok(Spline::new(s, id)) - } - pub fn create_ray(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Ray { - origin, - direction, - layer: None, - })?; - Ok(Ray::new(s, id)) - } - pub fn create_ray_on_layer( - &self, - origin: [f64; 3], - direction: [f64; 3], - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Ray { - origin, - direction, - layer: Some(layer.to_string()), - })?; - Ok(Ray::new(s, id)) - } - pub fn create_xline(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::XLine { - origin, - direction, - layer: None, - })?; - Ok(XLine::new(s, id)) - } - pub fn create_xline_on_layer( - &self, - origin: [f64; 3], - direction: [f64; 3], - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::XLine { - origin, - direction, - layer: Some(layer.to_string()), - })?; - Ok(XLine::new(s, id)) - } - /// A RASTER_IMAGE placed at `insertion_point` (host auto-registers the image definition). - pub fn create_raster_image( - &self, - file_path: &str, - insertion_point: [f64; 3], - u_vector: [f64; 3], - v_vector: [f64; 3], - size: [f64; 2], - ) -> ApiResult { - let receipt = - self.session - .apply_op(Operation::CreateRasterImage(crate::ops::RasterImageSpec { - file_path: file_path.to_string(), - insertion_point, - u_vector, - v_vector, - size, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_raster_image returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// A solid (or pattern) HATCH over a single closed polyline boundary. - pub fn create_hatch(&self, boundary: &[[f64; 2]], solid: bool) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateHatch(crate::ops::HatchSpec { - boundary: boundary.to_vec(), - solid, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_hatch returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// A 2-line angular DIMENSION: angle between lines (vertex→first) and (vertex→second). - pub fn create_dimension_angular2ln( - &self, - vertex: [f64; 3], - first_point: [f64; 3], - second_point: [f64; 3], - arc_location: [f64; 3], - ) -> ApiResult { - let receipt = self.session.apply_op(Operation::CreateDimensionAngular2Ln( - crate::ops::DimensionAngularSpec { - vertex, - first_point, - second_point, - arc_location, - }, - ))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?; - Ok(Dimension::new(self.session.clone(), id)) - } - /// A radial DIMENSION for the circle centered at `center` through `point`. - pub fn create_dimension_radius( - &self, - center: [f64; 3], - point: [f64; 3], - ) -> ApiResult { - self.create_dim_radial(Operation::CreateDimensionRadius, center, point) - } - /// A diameter DIMENSION for the circle with chord points `center`/`point`. - pub fn create_dimension_diameter( - &self, - center: [f64; 3], - point: [f64; 3], - ) -> ApiResult { - self.create_dim_radial(Operation::CreateDimensionDiameter, center, point) - } - fn create_dim_radial( - &self, - op: impl Fn(crate::ops::DimensionRadialSpec) -> Operation, - center: [f64; 3], - point: [f64; 3], - ) -> ApiResult { - let receipt = self - .session - .apply_op(op(crate::ops::DimensionRadialSpec { center, point }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?; - Ok(Dimension::new(self.session.clone(), id)) - } - /// A 3-point angular DIMENSION (vertex + one point on each leg). - pub fn create_dimension_angular( - &self, - vertex: [f64; 3], - first_point: [f64; 3], - second_point: [f64; 3], - arc_location: [f64; 3], - ) -> ApiResult { - let receipt = self.session.apply_op(Operation::CreateDimensionAngular( - crate::ops::DimensionAngularSpec { - vertex, - first_point, - second_point, - arc_location, - }, - ))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?; - Ok(Dimension::new(self.session.clone(), id)) - } - /// A linear DIMENSION between `first_point` and `second_point`, with the - /// dimension line placed at `definition_point`. `measurement()` reads the value. - pub fn create_dimension_linear( - &self, - first_point: [f64; 3], - second_point: [f64; 3], - definition_point: [f64; 3], - ) -> ApiResult { - let receipt = self.session.apply_op(Operation::CreateDimensionLinear( - crate::ops::DimensionSpec { - first_point, - second_point, - definition_point, - }, - ))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_dimension returned no id".into()))?; - Ok(Dimension::new(self.session.clone(), id)) - } - - /// Single-line TEXT annotation. - pub fn create_text( - &self, - value: &str, - insertion_point: [f64; 3], - height: f64, - rotation: f64, - ) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateText(crate::ops::TextSpec { - value: value.to_string(), - insertion_point, - height, - rotation, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_text returned no id".into()))?; - Ok(Text::new(self.session.clone(), id)) - } - /// Multi-line MTEXT annotation. - pub fn create_mtext( - &self, - value: &str, - insertion_point: [f64; 3], - height: f64, - ) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateMText(crate::ops::MTextSpec { - value: value.to_string(), - insertion_point, - height, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_mtext returned no id".into()))?; - Ok(MText::new(self.session.clone(), id)) - } - /// Bulk-create many points in ONE op: all-or-nothing, one undo step. - /// Places points on the current layer (`layer: None`). - pub fn create_points(&self, positions: &[[f64; 3]]) -> ApiResult> { - let specs: Vec = positions - .iter() - .map(|&p| { - crate::ops::EntitySpec::Curve(crate::ops::Curve2Spec::Point { - position: p, - layer: None, - }) - }) - .collect(); - let receipt = self.session.apply_op(Operation::CreateMany(specs))?; - let outcome = receipt - .outcome - .ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?; - Ok(outcome - .new_ids() - .iter() - .map(|&id| Point::new(self.session.clone(), id)) - .collect()) - } - - /// Bulk-create many points on a specific layer in ONE op. - pub fn create_points_on_layer( - &self, - positions: &[[f64; 3]], - layer: &str, - ) -> ApiResult> { - let specs: Vec = positions - .iter() - .map(|&p| { - crate::ops::EntitySpec::Curve(crate::ops::Curve2Spec::Point { - position: p, - layer: Some(layer.to_string()), - }) - }) - .collect(); - let receipt = self.session.apply_op(Operation::CreateMany(specs))?; - let outcome = receipt - .outcome - .ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?; - Ok(outcome - .new_ids() - .iter() - .map(|&id| Point::new(self.session.clone(), id)) - .collect()) - } -} - -#[derive(Clone)] -pub struct EntityCollection { - session: Session, -} - -impl EntityCollection { - /// Create a paper-space `VIEWPORT` (a `width`×`height` viewport at `center` - /// looking at `view_target` with `view_height` zoom). - pub fn create_viewport( - &self, - center: [f64; 3], - width: f64, - height: f64, - view_target: [f64; 3], - view_height: f64, - ) -> ApiResult { - let receipt = - self.session - .apply_op(Operation::CreateViewport(crate::ops::ViewportSpec { - center, - width, - height, - view_target, - view_height, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_viewport returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// Create a TABLE from a grid of cell strings (`data[row][column]`), at a point. - pub fn create_table( - &self, - insertion_point: [f64; 3], - data: &[Vec], - ) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateTable(crate::ops::TableSpec { - insertion_point, - data: data.to_vec(), - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_table returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// Create an ATTDEF (in-block attribute definition): tag/prompt/default at a point. - pub fn create_attribute_definition( - &self, - tag: &str, - prompt: &str, - default_value: &str, - insertion_point: [f64; 3], - height: f64, - rotation: f64, - ) -> ApiResult { - let receipt = self.session.apply_op(Operation::CreateAttributeDefinition( - crate::ops::AttributeDefinitionSpec { - tag: tag.to_string(), - prompt: prompt.to_string(), - default_value: default_value.to_string(), - insertion_point, - height, - rotation, - }, - ))?; - let id = receipt.outcome.and_then(|o| o.new_id()).ok_or_else(|| { - ApiError::Transport("create_attribute_definition returned no id".into()) - })?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// Place a block reference (`INSERT`) for `block_name` (must exist). - pub fn create_insert( - &self, - block_name: &str, - insert_point: [f64; 3], - scale: f64, - rotation: f64, - ) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateInsert(crate::ops::InsertSpec { - block_name: block_name.to_string(), - insert_point, - scale, - rotation, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_insert returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// Generic lookup by id (any family) → `Entity` (downcast via `as_solid()`, …). - pub fn get(&self, id: ObjectId) -> ApiResult { - // Validate existence eagerly so `get` errors surface as `UnknownId`. - self.session.one_query(Query::GetEntity { id })?; - Ok(Entity::new(self.session.clone(), id)) - } - pub fn delete(&self, id: ObjectId) -> ApiResult<()> { - self.session.apply_op(Operation::Delete { id })?; - Ok(()) - } - - /// Create a standalone `XRECORD` object. - pub fn create_xrecord(&self, spec: &XRecordSpec) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateXRecord(spec.clone()))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_xrecord returned no id".into()))?; - Ok(XRecord::new(self.session.clone(), id)) - } - - /// Lookup an `XRECORD` object by id and return a typed accessor handle. - pub fn get_xrecord(&self, id: ObjectId) -> ApiResult { - // Validate existence + that the payload is readable as an XRecord. - match self.session.one_query(Query::GetXRecord { id })? { - QueryResult::XRecord(_) => Ok(XRecord::new(self.session.clone(), id)), - _ => Err(ApiError::Transport("unexpected xrecord result".into())), - } - } - - /// Bulk-create many entities in ONE op (all-or-nothing, one undo step). - pub fn create_many(&self, specs: &[crate::ops::EntitySpec]) -> ApiResult> { - let receipt = self.session.apply_op(Operation::CreateMany(specs.to_vec()))?; - let outcome = receipt - .outcome - .ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?; - Ok(outcome - .new_ids() - .iter() - .map(|&id| Entity::new(self.session.clone(), id)) - .collect()) - } - - /// Bulk transform (one op, all-or-nothing). - pub fn transform_many(&self, ids: &[ObjectId], placement: PlacementSpec) -> ApiResult<()> { - self.session.apply_op(Operation::TransformMany { - ids: ids.to_vec(), - placement, - })?; - Ok(()) - } - /// Bulk delete (one op, all-or-nothing). Used by `OpGroup::compensate`. - pub fn delete_many(&self, ids: &[ObjectId]) -> ApiResult<()> { - self.session.apply_op(Operation::DeleteMany(ids.to_vec()))?; - Ok(()) - } -} - -// ── OpGroup: client-side failure-cleanup (NOT a transaction) ──────────────── - -/// Tracks handles created during a logical operation so a later failure can be -/// cleaned up with a bulk delete. Separate writes remain committed; -/// no host involvement, no rollback. -#[derive(Default)] -pub struct OpGroup { - created: Vec, - session: Option, -} - -impl OpGroup { - pub fn new() -> Self { - Self { - created: Vec::new(), - session: None, - } - } - /// Record a created handle (propagating any construction error); returns it - /// for fluent `grp.track(doc.solids().create_cuboid(..)?)?` use. - pub fn track(&mut self, handle: ApiResult) -> ApiResult { - let handle = handle?; - if let Some(session) = handle.session() { - if let Some(previous) = &self.session { - previous.same_transport(session)?; - } else { - self.session = Some(session.clone()); - } - } - if !self.created.contains(&handle.id()) { - self.created.push(handle.id()); - } - Ok(handle) - } - /// Keep everything: drop the tracking without deleting. - pub fn commit(self) {} - /// Best-effort cleanup: delete every tracked entity (one `DeleteMany` op). - /// Does not resurrect already-consumed/deleted entities. - pub fn compensate(self, doc: &Document) -> ApiResult<()> { - if let Some(session) = &self.session { - session.same_transport(&doc.session)?; - } - if self.created.is_empty() { - return Ok(()); - } - doc.entities().delete_many(&self.created) - } -} diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md deleted file mode 100644 index a0396caa1..000000000 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ /dev/null @@ -1,382 +0,0 @@ -# ocs_doc_api — API reference (auto-generated) - -Envelope version: `1` - -Do NOT edit: regenerated by `build.rs` from `spec/entities.toml`. - -## `Solid` (acadrust `Solid3D`, collection `solids`) - -B-rep solid. Host resolves bodies via the solid_models cache (lift-on-miss from ACIS SAT). - -### Constructors (`doc.solids()`) - -- **create_cuboid**(origin: [f64; 3], size: [f64; 3]) -> `Solid` — `CreateSolid`; fixed primitive="Cuboid" -- **create_sphere**(centre: [f64; 3], radius: f64) -> `Solid` — `CreateSolid`; fixed primitive="Sphere" -- **create_cylinder**(base: [f64; 3], radius: f64, height: f64) -> `Solid` — `CreateSolid`; fixed primitive="Cylinder" -- **create_cone**(base: [f64; 3], radius: f64, height: f64) -> `Solid` — `CreateSolid`; fixed primitive="Cone" -- **create_torus**(centre: [f64; 3], major_radius: f64, minor_radius: f64) -> `Solid` — `CreateSolid`; fixed primitive="Torus" -- **create_wedge**(origin: [f64; 3], size: [f64; 3]) -> `Solid` — `CreateSolid`; fixed primitive="Wedge" -- **extrude**(profile: EntityRef, direction: [f64; 3]) -> `Solid` — `Extrude` -- **revolve**(profile: EntityRef, pivot: [f64; 3], axis: [f64; 3], angle: f64) -> `Solid` — `Revolve` -- **loft**(profiles: &[EntityRef]) -> `Solid` — `Loft` - -### Methods - -- **intersect**(other: Solid) -> `Solid` — op `SolidBoolean`; fixed erase_sources=true, op="Intersection" -- **union**(other: Solid) -> `Solid` — op `SolidBoolean`; fixed erase_sources=true, op="Union" -- **subtract**(other: Solid) -> `Solid` — op `SolidBoolean`; fixed erase_sources=true, op="Difference" -- **volume**() -> `f64` — query `GetVolume` -- **centroid**() -> `[f64; 3]` — query `GetCentroid` -- **intersects**(other: Solid) -> `bool` — query `GetIntersects` - -## `Line` (acadrust `Line`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_line**(start: [f64; 3], end: [f64; 3]) -> `Line` — `CreateCurve`; fixed curve="Line" - -## `Circle` (acadrust `Circle`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_circle**(centre: [f64; 3], radius: f64) -> `Circle` — `CreateCurve`; fixed curve="Circle" - -## `Polyline` (acadrust `LwPolyline`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_polyline**(points: &[[f64; 3]], closed: bool) -> `Polyline` — `CreateCurve`; fixed curve="Polyline" - -### Methods - -- **add_vertex**(at: usize, point: [f64; 3]) -> `()` — op `AddVertex` - -## `Point` (acadrust `Point`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_point**(position: [f64; 3]) -> `Point` — `CreateCurve`; fixed curve="Point" -- **create_points**(positions: &[[f64; 3]]) -> `Vec` *(bulk op)* — `CreateMany`; fixed curve="Point" - -## `ArcCurve` (acadrust `Arc`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_arc**(centre: [f64; 3], radius: f64, start_angle: f64, end_angle: f64) -> `ArcCurve` — `CreateCurve`; fixed curve="Arc" - -## `Ellipse` (acadrust `Ellipse`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_ellipse**(centre: [f64; 3], major_axis: [f64; 3], ratio: f64, start: f64, end: f64) -> `Ellipse` — `CreateCurve`; fixed curve="Ellipse" - -## `Spline` (acadrust `Spline`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_spline**(degree: i32, control_points: &[[f64; 3]], knots: &[f64], weights: &[f64]) -> `Spline` — `CreateCurve`; fixed curve="Spline" - -## `Ray` (acadrust `Ray`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_ray**(origin: [f64; 3], direction: [f64; 3]) -> `Ray` — `CreateCurve`; fixed curve="Ray" - -## `XLine` (acadrust `XLine`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **create_xline**(origin: [f64; 3], direction: [f64; 3]) -> `XLine` — `CreateCurve`; fixed curve="XLine" - -## `Text` (acadrust `Text`, collection `curves`) - -Single-line text annotation. content()/set_content() read+replace the value; transform moves insertion point. - -### Constructors (`doc.curves()`) - -- **create_text**(value: &str, insertion_point: [f64; 3], height: f64, rotation: f64) -> `Text` — `CreateText` - -### Methods - -- **content**() -> `String` — query `GetTextContent` -- **set_content**(value: &str) -> `()` — op `SetTextContent` - -## `MText` (acadrust `MText`, collection `curves`) - -Multi-line text annotation. content()/set_content() read+replace the value; transform moves insertion point. - -### Constructors (`doc.curves()`) - -- **create_mtext**(value: &str, insertion_point: [f64; 3], height: f64) -> `MText` — `CreateMText` - -### Methods - -- **content**() -> `String` — query `GetTextContent` -- **set_content**(value: &str) -> `()` — op `SetTextContent` - -## `Entity` (acadrust `Hatch`, collection `curves`) - -Filled region over a closed polyline boundary. boundary() returns the loops; bounds from boundary; generic delete. - -### Constructors (`doc.curves()`) - -- **create_hatch**(boundary: &[[f64; 2]], solid: bool) -> `Entity` — `CreateHatch` - -### Methods - -- **boundary**() -> `Vec>` — query `GetHatchBoundary` - -## `Dimension` (acadrust `Dimension`, collection `curves`) - -Linear dimension between two measured points. measurement() reads the distance; bounds from the measured + definition points. - -### Constructors (`doc.curves()`) - -- **create_dimension_linear**(first_point: [f64; 3], second_point: [f64; 3], definition_point: [f64; 3]) -> `Dimension` — `CreateDimensionLinear` -- **create_dimension_radius**(center: [f64; 3], point: [f64; 3]) -> `Dimension` — `CreateDimensionRadius` -- **create_dimension_diameter**(center: [f64; 3], point: [f64; 3]) -> `Dimension` — `CreateDimensionDiameter` -- **create_dimension_angular**(vertex: [f64; 3], first_point: [f64; 3], second_point: [f64; 3], arc_location: [f64; 3]) -> `Dimension` — `CreateDimensionAngular` -- **create_dimension_angular2ln**(vertex: [f64; 3], first_point: [f64; 3], second_point: [f64; 3], arc_location: [f64; 3]) -> `Dimension` — `CreateDimensionAngular2Ln` - -### Methods - -- **measurement**() -> `f64` — query `GetDimensionMeasurement` - -## `Entity` (acadrust `Insert`, collection `entities`) - -Block reference. Place an existing BlockRecord by name; transform moves its insert point. - -### Constructors (`doc.entities()`) - -- **create_insert**(block_name: &str, insert_point: [f64; 3], scale: f64, rotation: f64) -> `Entity` — `CreateInsert` - -### Methods - -- **attributes**() -> `Vec<(String, String)>` — query `GetAttributes` -- **set_attribute**(tag: &str, value: &str) -> `()` — op `SetAttribute` - -## `Entity` (acadrust `AttributeDefinition`, collection `entities`) - -In-block attribute definition (ATTDEF): tag/prompt/default at an insertion point. - -### Constructors (`doc.entities()`) - -- **create_attribute_definition**(tag: &str, prompt: &str, default_value: &str, insertion_point: [f64; 3], height: f64, rotation: f64) -> `Entity` — `CreateAttributeDefinition` - -## `Entity` (acadrust `Viewport`, collection `entities`) - -Paper-space viewport: a width×height window at center looking at view_target with view_height zoom. bounds = center ± w/2,h/2; transform moves center. - -### Constructors (`doc.entities()`) - -- **create_viewport**(center: [f64; 3], width: f64, height: f64, view_target: [f64; 3], view_height: f64) -> `Entity` — `CreateViewport` - -### Methods - -- **viewport_view**() -> `([f64; 3], f64)` — query `GetViewportView` -- **set_view**(view_target: [f64; 3], view_height: f64) -> `()` — op `SetViewportView` - -## `Entity` (acadrust `RasterImage`, collection `entities`) - -Raster image: create_raster_image(file_path, insertion, u, v, size) places an image (host auto-registers the ImageDefinition); bounds (4-corner bracket); delete. - -### Constructors (`doc.entities()`) - -- **create_raster_image**(file_path: &str, insertion_point: [f64; 3], u_vector: [f64; 3], v_vector: [f64; 3], size: [f64; 2]) -> `Entity` — `CreateRasterImage` - -## `Entity` (acadrust `Table`, collection `entities`) - -Table: create_table(insertion_point, data[row][col]) builds rows x columns of text cells; non-rectangular grid -> Validation; delete. - -### Constructors (`doc.entities()`) - -- **create_table**(insertion_point: [f64; 3], data: &[Vec]) -> `Entity` — `CreateTable` - -## `XRecord` (acadrust `XRecord`, collection `entities`) - -Extended record (XRecord): arbitrary DXF group-code/value pairs. Roundtripped verbatim as an XRecordSpec payload. - -### Constructors (`doc.entities()`) - -- **create_xrecord**(spec: XRecordSpec) -> `XRecord` — `CreateXRecord` - -### Methods - -- **payload**() -> `XRecordSpec` — query `GetXRecord` -- **set_payload**(spec: XRecordSpec) -> `()` — op `SetXRecord` - -## `Layer` (acadrust `-`, collection `layers`) - -Named drawing layer (color, linetype, lineweight, visibility). Not an entity handle; accessed through Document::layers(). - -### Methods - -- **list**() -> `Vec` — query `ListLayers` -- **create**(info: LayerInfo) -> `()` — op `CreateLayer` -- **update**(name: String, info: LayerInfo) -> `()` — op `UpdateLayer` -- **delete**(name: String) -> `()` — op `DeleteLayer` - -## Generic methods (every handle) - -Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, -`Ellipse`, `Spline`, `Ray`, `XLine`, `Text`, `MText`, `Dimension`, `Entity`) is a -`Clone`, `Send`, `Sync` token `{ ObjectId, session }` and supports: - -- **`id()` -> `ObjectId`** — the stable entity identity. -- **`bounds()` -> `Aabb`** — coarse bounding box (solids lift-on-miss; 2D entities - computed; later families may be `Unsupported`). -- **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1: - solids only.** -- **`delete()`** — remove the entity (one undo step). -- **`layer()` -> `String`** — the name of the layer the entity is on. -- **`set_layer(layer)`** — move the entity to an existing layer (one undo step; - blocked if the entity or target layer is locked). - -`Entity` additionally has `view()` (id + kind + bounds), `as_solid()` (typed -downcast when `kind == "Solid3D"`), and XDATA access through -`xdata(application_name)` / `set_xdata(application_name, record)`. - -## Layer table - -Layers are a named document table, not an entity family. `Document::layers()` -lists all layers as `Vec`. Table-level ops are `CreateLayer(info)`, -`UpdateLayer { name, info }` and `DeleteLayer { name }`. `Entity::layer()` -returns the layer name and `Entity::set_layer(layer)` moves the entity to an -existing layer. The host rejects duplicate names, deletion of layer "0" or the -current layer, and removing a layer that still has entities assigned. - -## Transports - -- **`InProcess`** (feature `host`) — drives a `DocApiBackend` directly in the host. -- **`OcsPluginApiIpc`** (feature `ipc`) — serializes envelopes over the - `ocs_plugin_api` `PluginRequestSender`/`PluginRequest` channel. The host routes - `DocApiRequest { tab_id, bytes }` to the same executor. -- **`doc_api_for_host(host)`** (feature `doc_api_host`) — convenience helper that - builds a `DocApi` from any `ocs_plugin_api::host::HostApi` exposing a - `PluginRequestSender` (out-of-process / worker-thread plugins). - -## Collections & cross-cutting - -- **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`. -- **`Document`** — `solids()` / `curves()` / `entities()` factories + lookup, - `revision()`, `assert_revision(rev)` (read-guard), `query_batch(|q| …)` (read-only - batch, one round-trip, **no revision bump**). -- **`EntityCollection`** — `get(id) -> Entity`, `delete(id)`, - `transform_many(&ids, placement)`, `delete_many(&ids)`. -- **`OpGroup`** — best-effort client-side failure cleanup (`track` / `commit` / - `compensate`). **Not** a transaction: no atomicity, no rollback. - -## Bulk ops — single op, all-or-nothing, item-capped - -`CreateMany` / `TransformMany` / `DeleteMany` are **single** write ops: one undo -step, one publish, and **all-or-nothing via upfront validation** (no rollback). -Item-capped at `BULK_ITEM_CAP = 100_000` to bound host execution. Use them for -bulk data (N ≳ a few hundred); per-op autocommit is the right tool for -modeling-scale work. - -## Semantics - -- **Per-op autocommit, no write transactions.** Every write op is ONE atomic host - call = one undo step. Multi-op write transactions were removed (security/ - integrity review). -- **Per-op atomicity.** Validate + compute geometry **before** any mutation; a - failed op applies nothing and records no undo step. `erase_sources` booleans run - only on success. -- **Structured errors.** `ApiError::{Validation, Geometry, UnknownId, Unsupported, - Transport}`; bulk validation names the failing index; a stale handle returns - `UnknownId` (never a panic). - -## Kernel mapping - -| op | kernel call | returns | -|---|---|---| -| CreateSolid.Cuboid | `cadkernel::brep::make::cuboid` | Option | -| CreateSolid.Sphere | `cadkernel::brep::make::sphere` | Option | -| SolidBoolean | `cadkernel::brep::combine` | Result | -| Transform | `cadkernel::brep::transform` | Option | -| GetBounds | `cadkernel::brep::body_bounds` | Option | -| GetVolume | `cadkernel::brep::mesh_body + divergence` | computed | - -## Wire vocabulary - -Every `Operation` is ONE atomic write op (one undo step); every `Query` is read-only (safe to batch). Variants are append-only (bincode discriminant stability). Requests ride `DocApiEnvelope`: `Op(Operation)` for a write, `Queries(Vec)` for a read batch. - -### `Operation` - -- `CreateSolid(SolidPrimitive)` -- `CreateCurve(Curve2Spec)` -- `Extrude { profile: ObjectId, direction: [f64; 3] }` -- `Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }` -- `Transform { id: ObjectId, placement: PlacementSpec }` -- `SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }` -- `AddVertex { id: ObjectId, at: usize, point: [f64; 3] }` -- `Delete { id: ObjectId }` -- `CreateMany(Vec)` -- `TransformMany { ids: Vec, placement: PlacementSpec }` -- `DeleteMany(Vec)` -- `CreateInsert(InsertSpec)` -- `CreateViewport(ViewportSpec)` -- `CreateText(TextSpec)` -- `CreateMText(MTextSpec)` -- `SetTextContent { id: ObjectId, value: String }` -- `CreateHatch(HatchSpec)` -- `CreateDimensionLinear(DimensionSpec)` -- `SetAttribute { id: ObjectId, tag: String, value: String }` -- `SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }` -- `CreateRasterImage(RasterImageSpec)` -- `Loft { profiles: Vec }` -- `CreateDimensionRadius(DimensionRadialSpec)` -- `CreateDimensionDiameter(DimensionRadialSpec)` -- `CreateDimensionAngular(DimensionAngularSpec)` -- `CreateAttributeDefinition(AttributeDefinitionSpec)` -- `CreateTable(TableSpec)` -- `CreateDimensionAngular2Ln(DimensionAngularSpec)` -- `SetXData { id: ObjectId, application_name: String, record: Option }` -- `CreateXRecord(XRecordSpec)` -- `SetXRecord { id: ObjectId, spec: XRecordSpec }` -- `CreateLayer(LayerInfo)` -- `UpdateLayer { name: String, info: LayerInfo }` -- `DeleteLayer { name: String }` -- `SetEntityLayer { id: ObjectId, layer: String }` -- `SetXDataMany(Vec<(ObjectId, String, Option)` - -### `Query` - -- `GetEntity { id: ObjectId }` -- `GetBounds { id: ObjectId }` -- `GetCentroid { id: ObjectId }` -- `GetVolume { id: ObjectId }` -- `GetIntersects { a: ObjectId, b: ObjectId }` -- `GetGeometryRevision` -- `GetTextContent { id: ObjectId }` -- `GetHatchBoundary { id: ObjectId }` -- `GetDimensionMeasurement { id: ObjectId }` -- `GetAttributes { id: ObjectId }` -- `GetBlockEntities { block_name: String }` -- `GetViewportView { id: ObjectId }` -- `GetXData { id: ObjectId, application_name: String }` -- `GetXRecord { id: ObjectId }` -- `ListLayers` -- `GetEntityLayer { id: ObjectId }` - -## Errors (`ApiError`) - -| Variant | Meaning | -|---|---| -| `Validation { op, reason }` | Input rejected before any document mutation. | -| `Geometry { kind, msg }` | The kernel failed (boolean refused, invalid input, ACIS lift/lower). | -| `UnknownId(ObjectId)` | The handle is stale / deleted / never existed (never a panic). | -| `Unsupported(String)` | Capability not supported for this entity or backend. | -| `Transport(String)` | The channel failed (disconnected / oversized / timeout). | - -`ApiError` is serializable, so IPC returns the same structured error the in-process -executor produced. - -## Envelope & transport - -`DocApiEnvelope { version, body }` — `body` is `Op(Operation)` (a single write) or -`Queries(Vec)` (a read batch). bincode-serialized; `version` is the crate's -envelope version (bridges must check it). Transports: `InProcess` (host) and -`OcsPluginApiIpc` (plugin over `ocs_plugin_api`), both behind the `Transport: -Send + Sync` trait (`apply(envelope) -> Receipt`, `alive()`). `Receipt` carries -`outcome` (per-op result), `query_results`, and `new_revision`. diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json deleted file mode 100644 index 04697d696..000000000 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ /dev/null @@ -1,1349 +0,0 @@ -{ - "envelope_version": 1, - "families": [ - { - "acadrust_variant": "Solid3D", - "collection": "solids", - "constructors": [ - { - "args": [ - { - "name": "origin", - "ty": "[f64; 3]" - }, - { - "name": "size", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "primitive": "Cuboid" - }, - "name": "create_cuboid", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "primitive": "Sphere" - }, - "name": "create_sphere", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "base", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - }, - { - "name": "height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "primitive": "Cylinder" - }, - "name": "create_cylinder", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "base", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - }, - { - "name": "height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "primitive": "Cone" - }, - "name": "create_cone", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "major_radius", - "ty": "f64" - }, - { - "name": "minor_radius", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "primitive": "Torus" - }, - "name": "create_torus", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "origin", - "ty": "[f64; 3]" - }, - { - "name": "size", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "primitive": "Wedge" - }, - "name": "create_wedge", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "profile", - "ty": "EntityRef" - }, - { - "name": "direction", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "extrude", - "op": "Extrude", - "returns": "Solid" - }, - { - "args": [ - { - "name": "profile", - "ty": "EntityRef" - }, - { - "name": "pivot", - "ty": "[f64; 3]" - }, - { - "name": "axis", - "ty": "[f64; 3]" - }, - { - "name": "angle", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "revolve", - "op": "Revolve", - "returns": "Solid" - }, - { - "args": [ - { - "name": "profiles", - "ty": "&[EntityRef]" - } - ], - "bulk": false, - "fixed": null, - "name": "loft", - "op": "Loft", - "returns": "Solid" - } - ], - "handle": "Solid", - "methods": [ - { - "args": [ - { - "name": "other", - "ty": "Solid" - } - ], - "bulk": false, - "fixed": { - "erase_sources": true, - "op": "Intersection" - }, - "kind": "op", - "name": "intersect", - "op": "SolidBoolean", - "query": null, - "read_only": false, - "returns": "Solid" - }, - { - "args": [ - { - "name": "other", - "ty": "Solid" - } - ], - "bulk": false, - "fixed": { - "erase_sources": true, - "op": "Union" - }, - "kind": "op", - "name": "union", - "op": "SolidBoolean", - "query": null, - "read_only": false, - "returns": "Solid" - }, - { - "args": [ - { - "name": "other", - "ty": "Solid" - } - ], - "bulk": false, - "fixed": { - "erase_sources": true, - "op": "Difference" - }, - "kind": "op", - "name": "subtract", - "op": "SolidBoolean", - "query": null, - "read_only": false, - "returns": "Solid" - }, - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "volume", - "op": null, - "query": "GetVolume", - "read_only": true, - "returns": "f64" - }, - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "centroid", - "op": null, - "query": "GetCentroid", - "read_only": true, - "returns": "[f64; 3]" - }, - { - "args": [ - { - "name": "other", - "ty": "Solid" - } - ], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "intersects", - "op": null, - "query": "GetIntersects", - "read_only": true, - "returns": "bool" - } - ], - "name": "Solid" - }, - { - "acadrust_variant": "Line", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "start", - "ty": "[f64; 3]" - }, - { - "name": "end", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "curve": "Line" - }, - "name": "create_line", - "op": "CreateCurve", - "returns": "Line" - } - ], - "handle": "Line", - "methods": [], - "name": "Line" - }, - { - "acadrust_variant": "Circle", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "curve": "Circle" - }, - "name": "create_circle", - "op": "CreateCurve", - "returns": "Circle" - } - ], - "handle": "Circle", - "methods": [], - "name": "Circle" - }, - { - "acadrust_variant": "LwPolyline", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "points", - "ty": "&[[f64; 3]]" - }, - { - "name": "closed", - "ty": "bool" - } - ], - "bulk": false, - "fixed": { - "curve": "Polyline" - }, - "name": "create_polyline", - "op": "CreateCurve", - "returns": "Polyline" - } - ], - "handle": "Polyline", - "methods": [ - { - "args": [ - { - "name": "at", - "ty": "usize" - }, - { - "name": "point", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "add_vertex", - "op": "AddVertex", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "Polyline" - }, - { - "acadrust_variant": "Point", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "position", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "curve": "Point" - }, - "name": "create_point", - "op": "CreateCurve", - "returns": "Point" - }, - { - "args": [ - { - "name": "positions", - "ty": "&[[f64; 3]]" - } - ], - "bulk": true, - "fixed": { - "curve": "Point" - }, - "name": "create_points", - "op": "CreateMany", - "returns": "Vec" - } - ], - "handle": "Point", - "methods": [], - "name": "Point" - }, - { - "acadrust_variant": "Arc", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - }, - { - "name": "start_angle", - "ty": "f64" - }, - { - "name": "end_angle", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "curve": "Arc" - }, - "name": "create_arc", - "op": "CreateCurve", - "returns": "ArcCurve" - } - ], - "handle": "ArcCurve", - "methods": [], - "name": "Arc" - }, - { - "acadrust_variant": "Ellipse", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "major_axis", - "ty": "[f64; 3]" - }, - { - "name": "ratio", - "ty": "f64" - }, - { - "name": "start", - "ty": "f64" - }, - { - "name": "end", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "curve": "Ellipse" - }, - "name": "create_ellipse", - "op": "CreateCurve", - "returns": "Ellipse" - } - ], - "handle": "Ellipse", - "methods": [], - "name": "Ellipse" - }, - { - "acadrust_variant": "Spline", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "degree", - "ty": "i32" - }, - { - "name": "control_points", - "ty": "&[[f64; 3]]" - }, - { - "name": "knots", - "ty": "&[f64]" - }, - { - "name": "weights", - "ty": "&[f64]" - } - ], - "bulk": false, - "fixed": { - "curve": "Spline" - }, - "name": "create_spline", - "op": "CreateCurve", - "returns": "Spline" - } - ], - "handle": "Spline", - "methods": [], - "name": "Spline" - }, - { - "acadrust_variant": "Ray", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "origin", - "ty": "[f64; 3]" - }, - { - "name": "direction", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "curve": "Ray" - }, - "name": "create_ray", - "op": "CreateCurve", - "returns": "Ray" - } - ], - "handle": "Ray", - "methods": [], - "name": "Ray" - }, - { - "acadrust_variant": "XLine", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "origin", - "ty": "[f64; 3]" - }, - { - "name": "direction", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "curve": "XLine" - }, - "name": "create_xline", - "op": "CreateCurve", - "returns": "XLine" - } - ], - "handle": "XLine", - "methods": [], - "name": "XLine" - }, - { - "acadrust_variant": "Text", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "value", - "ty": "&str" - }, - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "height", - "ty": "f64" - }, - { - "name": "rotation", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_text", - "op": "CreateText", - "returns": "Text" - } - ], - "handle": "Text", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "content", - "op": null, - "query": "GetTextContent", - "read_only": true, - "returns": "String" - }, - { - "args": [ - { - "name": "value", - "ty": "&str" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_content", - "op": "SetTextContent", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "Text" - }, - { - "acadrust_variant": "MText", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "value", - "ty": "&str" - }, - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_mtext", - "op": "CreateMText", - "returns": "MText" - } - ], - "handle": "MText", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "content", - "op": null, - "query": "GetTextContent", - "read_only": true, - "returns": "String" - }, - { - "args": [ - { - "name": "value", - "ty": "&str" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_content", - "op": "SetTextContent", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "MText" - }, - { - "acadrust_variant": "Hatch", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "boundary", - "ty": "&[[f64; 2]]" - }, - { - "name": "solid", - "ty": "bool" - } - ], - "bulk": false, - "fixed": null, - "name": "create_hatch", - "op": "CreateHatch", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "boundary", - "op": null, - "query": "GetHatchBoundary", - "read_only": true, - "returns": "Vec>" - } - ], - "name": "Hatch" - }, - { - "acadrust_variant": "Dimension", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "first_point", - "ty": "[f64; 3]" - }, - { - "name": "second_point", - "ty": "[f64; 3]" - }, - { - "name": "definition_point", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_linear", - "op": "CreateDimensionLinear", - "returns": "Dimension" - }, - { - "args": [ - { - "name": "center", - "ty": "[f64; 3]" - }, - { - "name": "point", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_radius", - "op": "CreateDimensionRadius", - "returns": "Dimension" - }, - { - "args": [ - { - "name": "center", - "ty": "[f64; 3]" - }, - { - "name": "point", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_diameter", - "op": "CreateDimensionDiameter", - "returns": "Dimension" - }, - { - "args": [ - { - "name": "vertex", - "ty": "[f64; 3]" - }, - { - "name": "first_point", - "ty": "[f64; 3]" - }, - { - "name": "second_point", - "ty": "[f64; 3]" - }, - { - "name": "arc_location", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_angular", - "op": "CreateDimensionAngular", - "returns": "Dimension" - }, - { - "args": [ - { - "name": "vertex", - "ty": "[f64; 3]" - }, - { - "name": "first_point", - "ty": "[f64; 3]" - }, - { - "name": "second_point", - "ty": "[f64; 3]" - }, - { - "name": "arc_location", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_angular2ln", - "op": "CreateDimensionAngular2Ln", - "returns": "Dimension" - } - ], - "handle": "Dimension", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "measurement", - "op": null, - "query": "GetDimensionMeasurement", - "read_only": true, - "returns": "f64" - } - ], - "name": "Dimension" - }, - { - "acadrust_variant": "Insert", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "block_name", - "ty": "&str" - }, - { - "name": "insert_point", - "ty": "[f64; 3]" - }, - { - "name": "scale", - "ty": "f64" - }, - { - "name": "rotation", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_insert", - "op": "CreateInsert", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "attributes", - "op": null, - "query": "GetAttributes", - "read_only": true, - "returns": "Vec<(String, String)>" - }, - { - "args": [ - { - "name": "tag", - "ty": "&str" - }, - { - "name": "value", - "ty": "&str" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_attribute", - "op": "SetAttribute", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "Insert" - }, - { - "acadrust_variant": "AttributeDefinition", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "tag", - "ty": "&str" - }, - { - "name": "prompt", - "ty": "&str" - }, - { - "name": "default_value", - "ty": "&str" - }, - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "height", - "ty": "f64" - }, - { - "name": "rotation", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_attribute_definition", - "op": "CreateAttributeDefinition", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [], - "name": "AttributeDefinition" - }, - { - "acadrust_variant": "Viewport", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "center", - "ty": "[f64; 3]" - }, - { - "name": "width", - "ty": "f64" - }, - { - "name": "height", - "ty": "f64" - }, - { - "name": "view_target", - "ty": "[f64; 3]" - }, - { - "name": "view_height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_viewport", - "op": "CreateViewport", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "viewport_view", - "op": null, - "query": "GetViewportView", - "read_only": true, - "returns": "([f64; 3], f64)" - }, - { - "args": [ - { - "name": "view_target", - "ty": "[f64; 3]" - }, - { - "name": "view_height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_view", - "op": "SetViewportView", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "Viewport" - }, - { - "acadrust_variant": "RasterImage", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "file_path", - "ty": "&str" - }, - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "u_vector", - "ty": "[f64; 3]" - }, - { - "name": "v_vector", - "ty": "[f64; 3]" - }, - { - "name": "size", - "ty": "[f64; 2]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_raster_image", - "op": "CreateRasterImage", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [], - "name": "RasterImage" - }, - { - "acadrust_variant": "Table", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "data", - "ty": "&[Vec]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_table", - "op": "CreateTable", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [], - "name": "Table" - }, - { - "acadrust_variant": "XRecord", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "spec", - "ty": "XRecordSpec" - } - ], - "bulk": false, - "fixed": null, - "name": "create_xrecord", - "op": "CreateXRecord", - "returns": "XRecord" - } - ], - "handle": "XRecord", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "payload", - "op": null, - "query": "GetXRecord", - "read_only": true, - "returns": "XRecordSpec" - }, - { - "args": [ - { - "name": "spec", - "ty": "XRecordSpec" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_payload", - "op": "SetXRecord", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "XRecord" - }, - { - "acadrust_variant": null, - "collection": "layers", - "constructors": [], - "handle": "Layer", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "list", - "op": null, - "query": "ListLayers", - "read_only": true, - "returns": "Vec" - }, - { - "args": [ - { - "name": "info", - "ty": "LayerInfo" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "create", - "op": "CreateLayer", - "query": null, - "read_only": false, - "returns": "()" - }, - { - "args": [ - { - "name": "name", - "ty": "String" - }, - { - "name": "info", - "ty": "LayerInfo" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "update", - "op": "UpdateLayer", - "query": null, - "read_only": false, - "returns": "()" - }, - { - "args": [ - { - "name": "name", - "ty": "String" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "delete", - "op": "DeleteLayer", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "Layer" - } - ], - "layouts": { - "ObjectId": { - "struct": [ - { - "name": "0", - "type": "u64" - } - ] - }, - "Operation": { - "enum": [ - "CreateSolid(SolidPrimitive)", - "CreateCurve(Curve2Spec)", - "Extrude { profile: ObjectId, direction: [f64; 3] }", - "Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }", - "Transform { id: ObjectId, placement: PlacementSpec }", - "SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }", - "AddVertex { id: ObjectId, at: usize, point: [f64; 3] }", - "Delete { id: ObjectId }", - "CreateMany(Vec)", - "TransformMany { ids: Vec, placement: PlacementSpec }", - "DeleteMany(Vec)", - "CreateInsert(InsertSpec)", - "CreateViewport(ViewportSpec)", - "CreateText(TextSpec)", - "CreateMText(MTextSpec)", - "SetTextContent { id: ObjectId, value: String }", - "CreateHatch(HatchSpec)", - "CreateDimensionLinear(DimensionSpec)", - "SetAttribute { id: ObjectId, tag: String, value: String }", - "SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }", - "CreateRasterImage(RasterImageSpec)", - "Loft { profiles: Vec }", - "CreateDimensionRadius(DimensionRadialSpec)", - "CreateDimensionDiameter(DimensionRadialSpec)", - "CreateDimensionAngular(DimensionAngularSpec)", - "CreateAttributeDefinition(AttributeDefinitionSpec)", - "CreateTable(TableSpec)", - "CreateDimensionAngular2Ln(DimensionAngularSpec)", - "SetXData { id: ObjectId, application_name: String, record: Option }", - "CreateXRecord(XRecordSpec)", - "SetXRecord { id: ObjectId, spec: XRecordSpec }", - "CreateLayer(LayerInfo)", - "UpdateLayer { name: String, info: LayerInfo }", - "DeleteLayer { name: String }", - "SetEntityLayer { id: ObjectId, layer: String }", - "SetXDataMany(Vec<(ObjectId, String, Option)" - ] - }, - "Query": { - "enum": [ - "GetEntity { id: ObjectId }", - "GetBounds { id: ObjectId }", - "GetCentroid { id: ObjectId }", - "GetVolume { id: ObjectId }", - "GetIntersects { a: ObjectId, b: ObjectId }", - "GetGeometryRevision", - "GetTextContent { id: ObjectId }", - "GetHatchBoundary { id: ObjectId }", - "GetDimensionMeasurement { id: ObjectId }", - "GetAttributes { id: ObjectId }", - "GetBlockEntities { block_name: String }", - "GetViewportView { id: ObjectId }", - "GetXData { id: ObjectId, application_name: String }", - "GetXRecord { id: ObjectId }", - "ListLayers", - "GetEntityLayer { id: ObjectId }" - ] - } - }, - "object_model": { - "collections": [ - "solids", - "curves", - "entities" - ], - "document": "Document", - "handles": [ - "Entity", - "Solid", - "Line", - "Circle", - "Polyline", - "Point", - "ArcCurve", - "Ellipse", - "Spline", - "Ray", - "XLine", - "Text", - "MText", - "Dimension", - "XRecord", - "Layer" - ], - "root": "DocApi" - }, - "schema": "ocs_doc_api/binding_schema" -} \ No newline at end of file diff --git a/crates/ocs_doc_api/src/gen/layouts.json b/crates/ocs_doc_api/src/gen/layouts.json deleted file mode 100644 index b43755336..000000000 --- a/crates/ocs_doc_api/src/gen/layouts.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "ObjectId": { "struct": [ { "name": "0", "type": "u64" } ] }, - "Operation": { - "enum": [ - "CreateSolid(SolidPrimitive)", - "CreateCurve(Curve2Spec)", - "Extrude { profile: ObjectId, direction: [f64; 3] }", - "Revolve { profile: ObjectId, axis: ([f64; 3], [f64; 3]), angle: f64 }", - "Transform { id: ObjectId, placement: PlacementSpec }", - "SolidBoolean { op: BoolOp, a: ObjectId, b: ObjectId, erase_sources: bool }", - "AddVertex { id: ObjectId, at: usize, point: [f64; 3] }", - "Delete { id: ObjectId }", - "CreateMany(Vec)", - "TransformMany { ids: Vec, placement: PlacementSpec }", - "DeleteMany(Vec)", - "CreateInsert(InsertSpec)", - "CreateViewport(ViewportSpec)", - "CreateText(TextSpec)", - "CreateMText(MTextSpec)", - "SetTextContent { id: ObjectId, value: String }", - "CreateHatch(HatchSpec)", - "CreateDimensionLinear(DimensionSpec)", - "SetAttribute { id: ObjectId, tag: String, value: String }", - "SetViewportView { id: ObjectId, view_target: [f64; 3], view_height: f64 }", - "CreateRasterImage(RasterImageSpec)", - "Loft { profiles: Vec }", - "CreateDimensionRadius(DimensionRadialSpec)", - "CreateDimensionDiameter(DimensionRadialSpec)", - "CreateDimensionAngular(DimensionAngularSpec)", - "CreateAttributeDefinition(AttributeDefinitionSpec)", - "CreateTable(TableSpec)", - "CreateDimensionAngular2Ln(DimensionAngularSpec)", - "SetXData { id: ObjectId, application_name: String, record: Option }", - "CreateXRecord(XRecordSpec)", - "SetXRecord { id: ObjectId, spec: XRecordSpec }", - "CreateLayer(LayerInfo)", - "UpdateLayer { name: String, info: LayerInfo }", - "DeleteLayer { name: String }", - "SetEntityLayer { id: ObjectId, layer: String }", - "SetXDataMany(Vec<(ObjectId, String, Option)" - ] - }, - "Query": { - "enum": [ - "GetEntity { id: ObjectId }", - "GetBounds { id: ObjectId }", - "GetCentroid { id: ObjectId }", - "GetVolume { id: ObjectId }", - "GetIntersects { a: ObjectId, b: ObjectId }", - "GetGeometryRevision", - "GetTextContent { id: ObjectId }", - "GetHatchBoundary { id: ObjectId }", - "GetDimensionMeasurement { id: ObjectId }", - "GetAttributes { id: ObjectId }", - "GetBlockEntities { block_name: String }", - "GetViewportView { id: ObjectId }", - "GetXData { id: ObjectId, application_name: String }", - "GetXRecord { id: ObjectId }", - "ListLayers", - "GetEntityLayer { id: ObjectId }" - ] - } -} diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs deleted file mode 100644 index 7d8e91d22..000000000 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ /dev/null @@ -1,127 +0,0 @@ -// HAND-MAINTAINED wire vocabulary (the Operation enum). This file is NOT derived -// from spec/entities.toml by build.rs — the enum is the canonical append-only -// wire vocabulary; the spec's `op` names must each map to a variant here -// (enforced by a test). Append new variants at the END only (bincode discriminant -// stability). Do NOT reorder or remove variants. - -use serde::{Deserialize, Serialize}; - -use crate::id::ObjectId; -use crate::ops::{ - AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec, - DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, - SolidPrimitive, TableSpec, TextSpec, ViewportSpec, XDataRecord, XRecordSpec, -}; - -/// A typed write operation. Each variant is ONE atomic host call = one -/// undo step. Append new variants at the END only. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[non_exhaustive] -pub enum Operation { - // construction - CreateSolid(SolidPrimitive), - CreateCurve(Curve2Spec), - Extrude { - profile: ObjectId, - direction: [f64; 3], - }, - Revolve { - profile: ObjectId, - axis: ([f64; 3], [f64; 3]), - angle: f64, - }, - // modification - Transform { - id: ObjectId, - placement: PlacementSpec, - }, - SolidBoolean { - op: BoolOp, - a: ObjectId, - b: ObjectId, - erase_sources: bool, - }, - AddVertex { - id: ObjectId, - at: usize, - point: [f64; 3], - }, - Delete { - id: ObjectId, - }, - // Bulk ops (single op, upfront-validation all-or-nothing — NOT a transaction, §5.3) - CreateMany(Vec), - TransformMany { - ids: Vec, - placement: PlacementSpec, - }, - DeleteMany(Vec), - // containers - CreateInsert(InsertSpec), - // paper-space - CreateViewport(ViewportSpec), - // annotations - CreateText(TextSpec), - CreateMText(MTextSpec), - SetTextContent { - id: ObjectId, - value: String, - }, - // hatch - CreateHatch(HatchSpec), - // dimensions - CreateDimensionLinear(DimensionSpec), - // attributes - SetAttribute { - id: ObjectId, - tag: String, - value: String, - }, - // viewport view - SetViewportView { - id: ObjectId, - view_target: [f64; 3], - view_height: f64, - }, - // media - CreateRasterImage(RasterImageSpec), - // multi-profile solid - Loft { - profiles: Vec, - }, - // dimension sub-types - CreateDimensionRadius(DimensionRadialSpec), - CreateDimensionDiameter(DimensionRadialSpec), - CreateDimensionAngular(DimensionAngularSpec), - // attribute definitions - CreateAttributeDefinition(AttributeDefinitionSpec), - // tables - CreateTable(TableSpec), - // dimension sub-types (2-line angular) - CreateDimensionAngular2Ln(DimensionAngularSpec), - // xdata / xrecord roundtrip - SetXData { - id: ObjectId, - application_name: String, - record: Option, - }, - SetXDataMany(Vec<(ObjectId, String, Option)>), - CreateXRecord(XRecordSpec), - SetXRecord { - id: ObjectId, - spec: XRecordSpec, - }, - // layer CRUD - CreateLayer(crate::ops::LayerInfo), - UpdateLayer { - name: String, - info: crate::ops::LayerInfo, - }, - DeleteLayer { - name: String, - }, - SetEntityLayer { - id: ObjectId, - layer: String, - }, -} diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs deleted file mode 100644 index f58c6a0c8..000000000 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ /dev/null @@ -1,89 +0,0 @@ -// HAND-MAINTAINED wire vocabulary (the Query enum). NOT derived from the spec by -// build.rs — the enum is the canonical append-only wire vocabulary; the spec's -// `query` names must each map to a variant here (enforced by a test). Append new -// variants at the END only (bincode discriminant stability). - -use serde::{Deserialize, Serialize}; - -use crate::id::ObjectId; - -/// A typed read query. Read-only: no mutation, no undo, no revision -/// bump. Safe to batch (`EnvelopeBody::Queries`). Append new variants at the END only. -/// (Not `Copy`: some variants carry owned data like a block name.) -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[non_exhaustive] -pub enum Query { - GetEntity { - id: ObjectId, - }, - GetBounds { - id: ObjectId, - }, - GetCentroid { - id: ObjectId, - }, - GetVolume { - id: ObjectId, - }, - /// Whether two entities' bounds overlap (two-phase conditional modelling). - GetIntersects { - a: ObjectId, - b: ObjectId, - }, - GetGeometryRevision, - /// The text content of a Text/MText annotation. - GetTextContent { - id: ObjectId, - }, - /// The boundary loops of a Hatch (outer + islands) as polylines. - GetHatchBoundary { - id: ObjectId, - }, - /// The measured value of a Dimension (distance or angle). - GetDimensionMeasurement { - id: ObjectId, - }, - /// An insert's attributes as (tag, value) pairs. - GetAttributes { - id: ObjectId, - }, - /// The entity ids + kinds inside a block definition (read-only traversal). - GetBlockEntities { - block_name: String, - }, - /// A viewport's view (target + zoom height). - GetViewportView { - id: ObjectId, - }, - /// The XDATA record for `application_name` attached to an entity (`None` if absent). - GetXData { - id: ObjectId, - application_name: String, - }, - /// The XRECORD object payload by id. - GetXRecord { - id: ObjectId, - }, - /// List all layers in the document. - ListLayers, - /// The layer name of an entity. - GetEntityLayer { - id: ObjectId, - }, - /// Enumerate first-class entities, optionally filtered by kind and/or layer. - /// `include_bounds` populates `EntityView::bounds`; leave it false for a - /// lightweight list view. - EnumerateEntities { - kind: Option, - layer: Option, - include_bounds: bool, - }, - /// The WCS location of a Point entity. - GetPointPosition { - id: ObjectId, - }, - /// The start and end WCS points of a Line entity. - GetLineGeometry { - id: ObjectId, - }, -} diff --git a/crates/ocs_doc_api/src/geom.rs b/crates/ocs_doc_api/src/geom.rs deleted file mode 100644 index e77086d51..000000000 --- a/crates/ocs_doc_api/src/geom.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! Kernel geometry helpers (host feature, plan §10 code-mapping table). Thin -//! wrappers over `cadkernel::brep::*` mapping the crate's plain-data DTOs to the -//! non-serde kernel types. Kernel keys never cross the API (decision #7). - -use cadkernel::geom2d::Curve as Curve2; -use cadkernel::space::Plane; - -use crate::backend::KernelBody; -use crate::error::{ApiError, ApiResult, GeometryErrorKind}; -use crate::ops::{BoolOp, SolidPrimitive}; - -fn geom_err(kind: GeometryErrorKind, msg: impl Into) -> ApiError { - ApiError::geometry(kind, msg) -} - -/// Signed volume and centroid of a closed triangle mesh via the divergence -/// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source -/// of truth shared by the host backend and the test/example mock backends. -pub fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { - mesh.mass_properties().unwrap_or((0.0, [0.0; 3])) -} - -pub fn validate_placement(p: &crate::PlacementSpec) -> ApiResult<()> { - let placement = cadkernel::brep::Placement { - origin: p.origin, - x_axis: p.x_axis, - y_axis: p.y_axis, - z_axis: p.z_axis, - }; - if placement.scale().is_none() || p.origin.iter().any(|v| !v.is_finite()) { - return Err(ApiError::validation( - "Transform", - "placement must be a finite, nonzero similarity", - )); - } - Ok(()) -} - -/// Construct a solid primitive. -pub fn make_solid(p: &SolidPrimitive) -> ApiResult { - crate::validation::solid(p)?; - use cadkernel::brep::make; - let body = match *p { - SolidPrimitive::Cuboid { origin, size } => make::cuboid(origin, size), - SolidPrimitive::Sphere { centre, radius } => make::sphere(centre, radius), - SolidPrimitive::Cylinder { - base, - radius, - height, - } => make::cylinder(base, radius, height), - SolidPrimitive::Cone { - base, - radius, - height, - } => make::cone(base, radius, height), - SolidPrimitive::Torus { - centre, - major_radius, - minor_radius, - } => make::torus(centre, major_radius, minor_radius), - SolidPrimitive::Wedge { origin, size } => make::wedge(origin, size[0], size[1], size[2]), - }; - body.ok_or_else(|| { - geom_err( - GeometryErrorKind::InvalidInput, - "kernel make returned empty body", - ) - }) -} - -/// Boolean combine (pure): `brep::combine(a, b, op, operation_tolerance(&[&a,&b]))`. -/// Maps the kernel `Snag` to `ApiError::Geometry`. Does NOT mutate the inputs. -pub(crate) fn boolean(a: &KernelBody, b: &KernelBody, op: BoolOp) -> ApiResult { - let kernel_op = crate::executor::kernel_bool_op(op); - let tol = cadkernel::brep::operation_tolerance(&[a, b]); - cadkernel::brep::combine(a.clone(), b.clone(), kernel_op, tol) - .map_err(|e| geom_err(GeometryErrorKind::BooleanFailed, format!("{e:?}"))) -} - -/// Extrude a closed 2D profile into a solid (`brep::extrude`). -pub(crate) fn extrude( - plane: Plane, - profile: &[Curve2], - direction: [f64; 3], -) -> ApiResult { - cadkernel::brep::extrude(plane, profile, direction) - .ok_or_else(|| geom_err(GeometryErrorKind::InvalidInput, "extrude failed")) -} - -/// Revolve a closed 2D profile about an axis into a solid (`brep::revolve`). -pub(crate) fn revolve( - plane: Plane, - profile: &[Curve2], - pivot: [f64; 3], - axis: [f64; 3], - angle: f64, -) -> ApiResult { - cadkernel::brep::revolve(plane, profile, pivot, axis, angle) - .ok_or_else(|| geom_err(GeometryErrorKind::InvalidInput, "revolve failed")) -} diff --git a/crates/ocs_doc_api/src/id.rs b/crates/ocs_doc_api/src/id.rs deleted file mode 100644 index 87f4de167..000000000 --- a/crates/ocs_doc_api/src/id.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Stable entity identity. - -use serde::{Deserialize, Serialize}; - -/// A stable u64 handle to a first-level document entity, decoupled from any -/// internal engine/kernel pointer. Value-compatible with `acadrust::Handle` — -/// kernel keys never cross the API boundary. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(transparent)] -pub struct ObjectId(pub u64); - -impl ObjectId { - /// The null / absent handle (`acadrust::Handle::NULL` value `0`). - pub const NULL: ObjectId = ObjectId(0); - - pub const fn as_u64(self) -> u64 { - self.0 - } - - /// Build from a raw u64 (e.g. an `acadrust::Handle`'s value). - pub const fn from_u64(v: u64) -> Self { - Self(v) - } - - /// Build from an `acadrust::Handle` (host/ipc feature). - #[cfg(any(feature = "host", feature = "ipc"))] - pub fn from_handle(h: acadrust::Handle) -> Self { - Self(h.value()) - } - - /// Convert to an `acadrust::Handle` (host/ipc feature). - #[cfg(any(feature = "host", feature = "ipc"))] - pub fn to_handle(self) -> acadrust::Handle { - acadrust::Handle::new(self.0) - } -} diff --git a/crates/ocs_doc_api/src/layer.rs b/crates/ocs_doc_api/src/layer.rs deleted file mode 100644 index 48987f24f..000000000 --- a/crates/ocs_doc_api/src/layer.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Shared layer-name normalization rules. -//! -//! Layer names in OpenCADStudio/acadrust are case-insensitive, leading/trailing -//! whitespace is ignored, and empty names are invalid. The host (`normalize_name` -//! in `acadrust::tables`) implements the canonical rule set; this module re-exports -//! a compatible, dependency-free function for the wire side and for backends that -//! do not link against `acadrust` directly. - -/// Normalize a raw layer name into its case-insensitive lookup key. -/// -/// This mirrors `acadrust::tables::normalize_name` used by the host: -/// - trims leading and trailing ASCII whitespace -/// - converts the result to uppercase -/// - returns `""` if the name is empty after trimming -pub fn normalize_layer_name(name: &str) -> String { - name.trim().to_ascii_uppercase() -} - -/// A normalized layer name is non-empty and matches the host lookup key. -pub fn is_valid_layer_name(name: &str) -> bool { - !normalize_layer_name(name).is_empty() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalization_trims_and_uppercases() { - assert_eq!(normalize_layer_name(" Walls "), "WALLS"); - assert_eq!(normalize_layer_name("walls"), "WALLS"); - assert_eq!(normalize_layer_name("WALLS"), "WALLS"); - } - - #[test] - fn empty_and_whitespace_names_are_invalid() { - assert_eq!(normalize_layer_name(""), ""); - assert_eq!(normalize_layer_name(" "), ""); - assert!(!is_valid_layer_name("")); - assert!(!is_valid_layer_name(" ")); - } -} diff --git a/crates/ocs_doc_api/src/lib.rs b/crates/ocs_doc_api/src/lib.rs deleted file mode 100644 index 649f4bc84..000000000 --- a/crates/ocs_doc_api/src/lib.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Typed document operations, queries and facades over in-process or IPC transports. - -pub mod error; -pub mod id; -pub mod revision; - -#[cfg(feature = "host")] -pub mod backend; -#[cfg(feature = "host")] -pub mod convert; -#[cfg(feature = "host")] -pub mod executor; -#[cfg(feature = "host")] -pub mod geom; -#[cfg(feature = "host")] -mod validation; - -pub mod envelope; -pub mod facade; -pub mod layer; -pub mod ops; -pub mod query; -pub mod transport; - -// Append-only wire enums, maintained alongside the DTOs and spec. -pub mod gen { - pub mod ops { - include!("gen/ops_gen.rs"); - } - pub mod query { - include!("gen/query_gen.rs"); - } - pub use ops::Operation; - pub use query::Query; -} - -pub use error::{ApiError, ApiResult, GeometryErrorKind}; -pub use id::ObjectId; -pub use revision::GeometryRevision; - -pub use envelope::{DocApiEnvelope, EnvelopeBody, OpOutcome, Receipt}; -pub use facade::{ - ArcCurve, Circle, CurveCollection, Dimension, DocApi, Document, Ellipse, Entity, - EntityCollection, HasId, Line, MText, OpGroup, Point, Polyline, QueryBatch, QueryResults, Ray, - Solid, SolidCollection, Spline, Text, XLine, XRecord, -}; -pub use layer::{is_valid_layer_name, normalize_layer_name}; -pub use ops::{ - BoolOp, Color, Curve2Spec, EntitySpec, LayerFlags, LayerInfo, LineWeight, Operation, - PlacementSpec, SolidPrimitive, XDataRecord, XDataValue, XRecordCloningFlags, XRecordEntry, - XRecordSpec, XRecordValue, -}; -pub use query::{Aabb, EntityView, Query, QueryResult}; - -#[cfg(feature = "host")] -pub use convert::{ - bulge_arc_segment, curve_spec_to_entity, entity_bounds, entity_kind_name, - entity_to_profile_curves, transform_entity_geometry, -}; -#[cfg(feature = "doc_api_host")] -pub use transport::ipc::doc_api_for_host; - -pub use transport::Transport; - -/// The crate's own wire-envelope protocol version (`DocApiEnvelope::version`). -/// Bump when the envelope layout or `Operation`/`Query` discriminants change. -/// Bridges must check this before speaking (see `bindings/README.md`). -pub const ENVELOPE_VERSION: u16 = 1; - -/// The generated binding handover schema: object model + method -/// signatures + op/query mapping with curated wire vocabulary. Single, -/// self-contained, versioned with [`ENVELOPE_VERSION`]. -pub fn binding_schema_json() -> &'static str { - include_str!("gen/binding_schema.json") -} diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs deleted file mode 100644 index 7941b4112..000000000 --- a/crates/ocs_doc_api/src/ops.rs +++ /dev/null @@ -1,489 +0,0 @@ -//! Write operations — the typed, transport-agnostic vocabulary for changing the -//! document. The `Operation` enum is **hand-maintained** (append-only) -//! in `src/gen/ops_gen.rs` — it is NOT derived from the spec by build.rs; the -//! spec's `op` names must each map to a variant here (asserted by a test). This -//! module re-exports it and holds the hand-written payload mirrors. - -use serde::{Deserialize, Serialize}; - -pub use crate::gen::Operation; - -/// An XDATA record payload: one registered application name plus its typed -/// value list. This is a plain-data, serde-compatible mirror of -/// `acadrust::xdata::ExtendedDataRecord` so the wire DTO stays independent of -/// the host dependency. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct XDataRecord { - pub application_name: String, - pub values: Vec, -} - -/// A single XDATA value. Mirrors `acadrust::xdata::XDataValue`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum XDataValue { - String(String), - ControlString(String), - LayerName(String), - BinaryData(Vec), - Handle(u64), - Point3D([f64; 3]), - Position3D([f64; 3]), - Displacement3D([f64; 3]), - Direction3D([f64; 3]), - Real(f64), - Distance(f64), - ScaleFactor(f64), - Integer16(i16), - Integer32(i32), -} - -/// Construction spec for an `XRECORD` object. Mirrors the acadrust -/// `XRecord` payload (name + cloning flags + group-code entries) in a -/// serde-compatible, host-independent form. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct XRecordSpec { - pub name: String, - pub cloning_flags: XRecordCloningFlags, - pub entries: Vec, -} - -/// XRecord cloning behavior flags. Mirror of -/// `acadrust::objects::DictionaryCloningFlags`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub enum XRecordCloningFlags { - #[default] - NotApplicable, - KeepExisting, - UseClone, - XrefName, - Name, - UnmangleName, -} - -/// One XRECORD group-code entry. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct XRecordEntry { - pub code: i32, - pub value: XRecordValue, -} - -/// A single XRECORD value. Mirrors `acadrust::objects::XRecordValue`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum XRecordValue { - String(String), - Double(f64), - Int16(i16), - Int32(i32), - Int64(i64), - Byte(u8), - Bool(bool), - Handle(u64), - Point3D([f64; 3]), - Chunk(Vec), -} - -/// Boolean operation kind for [`Operation::SolidBoolean`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum BoolOp { - Union, - Intersection, - Difference, -} - -/// A plain-data color value. Mirrors `acadrust::types::Color` for wire -/// independence from the host dependency. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum Color { - ByLayer, - None, - ByBlock, - Index(u8), - Rgb { r: u8, g: u8, b: u8 }, -} - -impl Color { - pub const WHITE: Color = Color::Index(7); -} - -/// A plain-data line weight. Mirrors `acadrust::types::LineWeight`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub enum LineWeight { - #[default] - ByLayer, - ByBlock, - Default, - /// Specific line weight in 1/100 mm. - Value(i16), -} - -/// Layer visibility/lock flags. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct LayerFlags { - pub frozen: bool, - pub locked: bool, - pub frozen_in_new_viewport: bool, - pub off: bool, -} - -/// A plain-data layer table entry. Mirrors `acadrust::tables::Layer`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct LayerInfo { - pub name: String, - pub flags: LayerFlags, - pub color: Color, - pub line_type: String, - pub line_weight: LineWeight, - pub plot_style: String, - pub is_plottable: bool, -} - -impl LayerInfo { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - flags: LayerFlags::default(), - color: Color::WHITE, - line_type: "Continuous".to_string(), - line_weight: LineWeight::Default, - plot_style: String::new(), - is_plottable: true, - } - } - - /// The normalized (trimmed + uppercased) lookup key for this layer name. - pub fn normalized_name(&self) -> String { - crate::layer::normalize_layer_name(&self.name) - } -} - -/// Construction spec for a B-rep solid primitive (plain-data mirror of the -/// non-serde `cadkernel::brep::make::*` arguments). -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub enum SolidPrimitive { - Cuboid { - origin: [f64; 3], - size: [f64; 3], - }, - Sphere { - centre: [f64; 3], - radius: f64, - }, - Cylinder { - base: [f64; 3], - radius: f64, - height: f64, - }, - Cone { - base: [f64; 3], - radius: f64, - height: f64, - }, - Torus { - centre: [f64; 3], - major_radius: f64, - minor_radius: f64, - }, - Wedge { - origin: [f64; 3], - size: [f64; 3], - }, -} - -/// Construction spec for a minimal 2D curve entity (phase-1 set; profiles/inputs). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum Curve2Spec { - Line { - start: [f64; 3], - end: [f64; 3], - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - Circle { - centre: [f64; 3], - radius: f64, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - Polyline { - points: Vec<[f64; 3]>, - closed: bool, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - Point { - position: [f64; 3], - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// Circular arc, counter-clockwise from `start_angle` to `end_angle` (radians). - Arc { - centre: [f64; 3], - radius: f64, - start_angle: f64, - end_angle: f64, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// Ellipse: `major_axis` is the major-axis endpoint relative to `centre`; - /// `ratio` = minor/major. `start`/`end` parameters 0..2π (full ellipse = 0..2π). - Ellipse { - centre: [f64; 3], - major_axis: [f64; 3], - ratio: f64, - start: f64, - end: f64, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// NURBS spline (control points + knots + weights; `degree` typically 3). - Spline { - degree: i32, - control_points: Vec<[f64; 3]>, - knots: Vec, - weights: Vec, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// A ray from `origin` along `direction` (bounded at origin only). - Ray { - origin: [f64; 3], - direction: [f64; 3], - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// An infinite construction line through `origin` along `direction`. - XLine { - origin: [f64; 3], - direction: [f64; 3], - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, -} - -impl Curve2Spec { - /// The target layer name, if any. - pub fn layer(&self) -> Option<&str> { - match self { - Curve2Spec::Line { layer, .. } - | Curve2Spec::Circle { layer, .. } - | Curve2Spec::Polyline { layer, .. } - | Curve2Spec::Point { layer, .. } - | Curve2Spec::Arc { layer, .. } - | Curve2Spec::Ellipse { layer, .. } - | Curve2Spec::Spline { layer, .. } - | Curve2Spec::Ray { layer, .. } - | Curve2Spec::XLine { layer, .. } => layer.as_deref(), - } - } -} - -/// A generic entity-construction payload used by the bulk op -/// [`crate::ops::Operation::CreateMany`]. Mirrors the per-family construction -/// specs so one bulk op can carry many homogeneous (or mixed) creations. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum EntitySpec { - Curve(Curve2Spec), - Solid(SolidPrimitive), -} - -impl EntitySpec { - /// The target layer name for this spec, if explicitly set. - pub fn layer(&self) -> Option<&str> { - match self { - EntitySpec::Curve(c) => c.layer(), - EntitySpec::Solid(_) => None, - } - } -} - -/// Construction spec for a block reference (`INSERT`): place the block -/// `block_name` (must exist as a BlockRecord) at `insert_point` with uniform -/// `scale` and `rotation` (radians, about Z). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct InsertSpec { - pub block_name: String, - pub insert_point: [f64; 3], - pub scale: f64, - pub rotation: f64, -} - -/// Construction spec for a paper-space `VIEWPORT`: a `width`×`height` viewport -/// centered at `center` (paper space), looking at `view_target` (model space, -/// WCS) with `view_height` (model-space height visible; the viewport's zoom). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ViewportSpec { - pub center: [f64; 3], - pub width: f64, - pub height: f64, - pub view_target: [f64; 3], - pub view_height: f64, -} - -/// Construction spec for a single-line `TEXT` annotation. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TextSpec { - pub value: String, - pub insertion_point: [f64; 3], - pub height: f64, - pub rotation: f64, -} - -/// Construction spec for a multi-line `MTEXT` annotation. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct MTextSpec { - pub value: String, - pub insertion_point: [f64; 3], - pub height: f64, -} - -/// Construction spec for a `HATCH` with a single closed polyline boundary -/// (straight segments). `solid` selects solid fill vs a pattern (pattern naming -/// is a later refinement). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct HatchSpec { - /// Closed boundary as an ordered polyline (first==last implied; closed). - pub boundary: Vec<[f64; 2]>, - pub solid: bool, -} - -/// Construction spec for a linear `DIMENSION`: the two measured points and the -/// dimension-line definition point. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DimensionSpec { - pub first_point: [f64; 3], - pub second_point: [f64; 3], - pub definition_point: [f64; 3], -} - -/// Construction spec for a radial/diameter `DIMENSION`: the circle center and a -/// point on the circle (radius) or a chord point (diameter). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DimensionRadialSpec { - /// Center of the arc/circle (radius) or first chord point (diameter). - pub center: [f64; 3], - /// A point on the arc/circle (radius) or the far chord point (diameter). - pub point: [f64; 3], -} - -/// Construction spec for a 3-point angular `DIMENSION`: vertex + one point on -/// each leg, plus the dimension-arc location. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DimensionAngularSpec { - pub vertex: [f64; 3], - pub first_point: [f64; 3], - pub second_point: [f64; 3], - pub arc_location: [f64; 3], -} - -/// Construction spec for an `ATTDEF` (attribute definition): the tag, prompt, -/// and default value for an in-block attribute, at an insertion point. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AttributeDefinitionSpec { - pub tag: String, - pub prompt: String, - pub default_value: String, - pub insertion_point: [f64; 3], - pub height: f64, - pub rotation: f64, -} - -/// Construction spec for a `TABLE`: an insertion point plus a grid of cell -/// strings (`data[row][column]`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TableSpec { - pub insertion_point: [f64; 3], - /// Grid of cell text values, rows × columns. - pub data: Vec>, -} - -/// Construction spec for a `RASTER_IMAGE`: an image file placed at -/// `insertion_point` with per-pixel world-unit vectors `u_vector`/`v_vector` and -/// pixel `size` (width, height). The host auto-registers the ImageDefinition. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RasterImageSpec { - pub file_path: String, - pub insertion_point: [f64; 3], - pub u_vector: [f64; 3], - pub v_vector: [f64; 3], - /// Pixel size (width, height). - pub size: [f64; 2], -} - -/// A rigid similarity (rotation + translation + optional uniform scale/reflection) -/// as a plain-data mirror of `cadkernel::brep::transform`'s `Placement` -/// (non-serde). Columns are the new basis axes; `origin` is the translation. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct PlacementSpec { - pub x_axis: [f64; 3], - pub y_axis: [f64; 3], - pub z_axis: [f64; 3], - pub origin: [f64; 3], -} - -impl PlacementSpec { - /// Identity placement (no transform). - pub const IDENTITY: PlacementSpec = PlacementSpec { - x_axis: [1.0, 0.0, 0.0], - y_axis: [0.0, 1.0, 0.0], - z_axis: [0.0, 0.0, 1.0], - origin: [0.0, 0.0, 0.0], - }; - - /// Pure translation to `origin` (axes unchanged). - pub fn at(origin: [f64; 3]) -> Self { - Self { - origin, - ..Self::IDENTITY - } - } -} - -/// The item-count cap for a single bulk op envelope: bounds -/// host-side execution time and payload. Over-cap → `ApiError::Validation`. -pub const BULK_ITEM_CAP: usize = 100_000; - -/// Convenience accessors used by the facade/executor. -impl crate::gen::Operation { - /// The op name used in `ApiError::Validation { op, .. }`. - pub fn op_name(&self) -> &'static str { - use crate::gen::Operation::*; - match self { - CreateSolid(_) => "CreateSolid", - CreateCurve(_) => "CreateCurve", - Extrude { .. } => "Extrude", - Revolve { .. } => "Revolve", - Transform { .. } => "Transform", - SolidBoolean { .. } => "SolidBoolean", - AddVertex { .. } => "AddVertex", - Delete { .. } => "Delete", - CreateMany(_) => "CreateMany", - TransformMany { .. } => "TransformMany", - DeleteMany(_) => "DeleteMany", - CreateInsert(_) => "CreateInsert", - CreateViewport(_) => "CreateViewport", - CreateText(_) => "CreateText", - CreateMText(_) => "CreateMText", - SetTextContent { .. } => "SetTextContent", - CreateHatch(_) => "CreateHatch", - CreateDimensionLinear(_) => "CreateDimensionLinear", - SetAttribute { .. } => "SetAttribute", - SetViewportView { .. } => "SetViewportView", - CreateRasterImage(_) => "CreateRasterImage", - Loft { .. } => "Loft", - CreateDimensionRadius(_) => "CreateDimensionRadius", - CreateDimensionDiameter(_) => "CreateDimensionDiameter", - CreateDimensionAngular(_) => "CreateDimensionAngular", - CreateAttributeDefinition(_) => "CreateAttributeDefinition", - CreateTable(_) => "CreateTable", - CreateDimensionAngular2Ln(_) => "CreateDimensionAngular2Ln", - SetXData { .. } => "SetXData", - SetXDataMany(_) => "SetXDataMany", - CreateXRecord(_) => "CreateXRecord", - SetXRecord { .. } => "SetXRecord", - CreateLayer(_) => "CreateLayer", - UpdateLayer { .. } => "UpdateLayer", - DeleteLayer { .. } => "DeleteLayer", - SetEntityLayer { .. } => "SetEntityLayer", - } - } -} diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs deleted file mode 100644 index e53d2b650..000000000 --- a/crates/ocs_doc_api/src/query.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! Read queries — typed, transport-agnostic reads of entity state. -//! The `Query` enum is **hand-maintained** (append-only) in `src/gen/query_gen.rs` -//! — NOT derived from the spec by build.rs (the spec's `query` names must map to -//! a variant here, asserted by a test). This module re-exports it and holds the -//! hand-written result DTOs. - -use serde::{Deserialize, Serialize}; - -use crate::id::ObjectId; -use crate::revision::GeometryRevision; - -pub use crate::gen::Query; - -/// Axis-aligned bounding box (plain-data mirror of the kernel's non-serde `Aabb`). -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct Aabb { - pub min: [f64; 3], - pub max: [f64; 3], -} - -impl Aabb { - #[cfg(feature = "host")] - pub fn overlaps(&self, other: &Aabb) -> bool { - cadkernel::brep::Aabb { - min: self.min, - max: self.max, - } - .overlaps(&cadkernel::brep::Aabb { - min: other.min, - max: other.max, - }) - } -} - -/// A generic, untyped view of an entity returned by [`Query::GetEntity`]. -/// Family-specific data lives in the typed construction specs; this carries -/// identity + kind + a coarse bounding box so any first-level entity is readable. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct EntityView { - pub id: ObjectId, - /// The acadrust `EntityType` variant name (e.g. "Solid3D", "Line"). - pub kind: String, - pub bounds: Option, - /// The normalized (uppercase) layer name the entity is on. - pub layer: String, -} - -/// The result of a single [`Query`] (one per query in a `Queries` batch). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum QueryResult { - Entity(EntityView), - Bounds(Aabb), - Centroid([f64; 3]), - Volume(f64), - /// Whether two entities' bounds overlap (two-phase conditional modelling). - Intersects(bool), - Revision(GeometryRevision), - /// The text content of a Text/MText annotation. - TextContent(String), - /// A hatch's boundary loops (outer + islands), each a closed polyline. - HatchBoundary(Vec>), - /// A dimension's measured value (distance for linear/radius, degrees for angular). - DimensionMeasurement(f64), - /// An insert's attributes as (tag, value) pairs. - Attributes(Vec<(String, String)>), - /// The entities inside a block definition (read-only traversal). - BlockEntities(Vec), - /// A viewport's view: target point (WCS) + zoom height. - ViewportView { - target: [f64; 3], - height: f64, - }, - /// An XDATA record for a single application (`None` if no such record). - XData(Option), - /// An XRECORD object payload. - XRecord(crate::ops::XRecordSpec), - /// All layers in the document. - Layers(Vec), - /// The layer name of an entity. - EntityLayer(String), - /// A filtered list of entity views. - Entities(Vec), - /// The WCS location of a Point entity. - PointPosition([f64; 3]), - /// The start and end WCS points of a Line entity. - LineGeometry(([f64; 3], [f64; 3])), -} - -/// Convenience: the query name for diagnostics. -impl crate::gen::Query { - pub fn query_name(&self) -> &'static str { - use crate::gen::Query::*; - match self { - GetEntity { .. } => "GetEntity", - GetBounds { .. } => "GetBounds", - GetCentroid { .. } => "GetCentroid", - GetVolume { .. } => "GetVolume", - GetIntersects { .. } => "GetIntersects", - GetGeometryRevision => "GetGeometryRevision", - GetTextContent { .. } => "GetTextContent", - GetHatchBoundary { .. } => "GetHatchBoundary", - GetDimensionMeasurement { .. } => "GetDimensionMeasurement", - GetAttributes { .. } => "GetAttributes", - GetBlockEntities { .. } => "GetBlockEntities", - GetViewportView { .. } => "GetViewportView", - GetXData { .. } => "GetXData", - GetXRecord { .. } => "GetXRecord", - ListLayers => "ListLayers", - GetEntityLayer { .. } => "GetEntityLayer", - EnumerateEntities { .. } => "EnumerateEntities", - GetPointPosition { .. } => "GetPointPosition", - GetLineGeometry { .. } => "GetLineGeometry", - } - } -} diff --git a/crates/ocs_doc_api/src/revision.rs b/crates/ocs_doc_api/src/revision.rs deleted file mode 100644 index f0759afe9..000000000 --- a/crates/ocs_doc_api/src/revision.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Geometry revision counter (mirrors the host scene's `geometry_epoch`). - -use serde::{Deserialize, Serialize}; - -/// Monotonic revision of a document's geometry. Bumped exactly once per applied -/// write op (`push_undo` + `finalize_op`). Pure queries return the current -/// revision without bumping it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(transparent)] -pub struct GeometryRevision(pub u64); - -impl GeometryRevision { - pub const fn as_u64(self) -> u64 { - self.0 - } -} diff --git a/crates/ocs_doc_api/src/transport/inproc.rs b/crates/ocs_doc_api/src/transport/inproc.rs deleted file mode 100644 index 8d9353024..000000000 --- a/crates/ocs_doc_api/src/transport/inproc.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! In-process transport: drives a `DocApiBackend` directly, inline. -//! Used by in-tree/built-in plugins and host tests. Thread-safe via a mutex so -//! many handles across many threads share it (calls are serialized). - -use std::sync::Mutex; - -use crate::backend::DocApiBackend; -use crate::envelope::{DocApiEnvelope, EnvelopeBody, Receipt}; -use crate::error::ApiResult; -use crate::executor; -use crate::transport::Transport; - -/// In-process transport: holds the backend behind a mutex (reentrant `&self`). -pub struct InProcess { - backend: Mutex, -} - -impl InProcess { - pub fn new(backend: B) -> Self { - Self { - backend: Mutex::new(backend), - } - } - /// Borrow the backend immutably (test/introspection helper). - pub fn backend(&self) -> std::sync::MutexGuard<'_, B> { - self.backend.lock().expect("in-process backend poisoned") - } -} - -impl Transport for InProcess { - fn apply(&self, req: DocApiEnvelope) -> ApiResult { - req.validate_version()?; - let mut backend = self - .backend - .lock() - .map_err(|_| crate::error::ApiError::Transport("in-process backend poisoned".into()))?; - match req.body { - EnvelopeBody::Op(op) => executor::apply_op(&mut *backend, op), - EnvelopeBody::Queries(queries) => executor::apply_queries(&mut *backend, queries), - } - } -} diff --git a/crates/ocs_doc_api/src/transport/ipc.rs b/crates/ocs_doc_api/src/transport/ipc.rs deleted file mode 100644 index f9b4b1d3e..000000000 --- a/crates/ocs_doc_api/src/transport/ipc.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! IPC transport: serializes `DocApiEnvelope` into the -//! append-only `PluginRequest::DocApiRequest { tab_id, bytes }` variant and ships -//! it through the plugin's `PluginRequestSender`. The host routes that variant to -//! the same crate executor (`ocs_doc_api::executor`) — one implementation. - -use std::sync::Arc; - -use ocs_plugin_api::host::{HostApi, PluginRequestError, PluginRequestSender}; -use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; - -use crate::envelope::{DocApiEnvelope, Receipt}; -use crate::error::{ApiError, ApiResult}; -use crate::facade::DocApi; -use crate::transport::Transport; - -/// Out-of-process IPC transport over the `ocs_plugin_api` channel. `Send + Sync`; -/// the underlying `PluginRequestSender` already serializes + correlates (V4). -pub struct OcsPluginApiIpc { - sender: Arc, - tab_id: u64, -} - -impl OcsPluginApiIpc { - pub fn new(sender: Arc, tab_id: u64) -> Self { - Self { sender, tab_id } - } - - fn transport_err(e: PluginRequestError) -> ApiError { - ApiError::Transport(e.0) - } -} - -/// Build a typed [`DocApi`] from any host that can provide a -/// [`PluginRequestSender`] (out-of-process / V4 worker plugins). -/// -/// Returns `None` for hosts that do not expose a request sender (e.g. in-process -/// hosts). In-process code should use [`crate::DocApi::in_process`] with a -/// concrete [`crate::backend::DocApiBackend`]. -pub fn doc_api_for_host(host: &dyn HostApi) -> Option { - let sender = host.plugin_request_sender()?; - let tab_id = host.tab_id(); - Some(DocApi::connect( - Arc::new(OcsPluginApiIpc::new(sender.into(), tab_id)), - tab_id, - )) -} - -impl Transport for OcsPluginApiIpc { - fn apply(&self, req: DocApiEnvelope) -> ApiResult { - let bytes = bincode::serialize(&req) - .map_err(|e| ApiError::Transport(format!("envelope serialize: {e}")))?; - let resp = self - .sender - .request(PluginRequest::DocApiRequest { - tab_id: self.tab_id, - bytes, - }) - .map_err(Self::transport_err)?; - match resp { - PluginResponse::DocApiResponse { bytes } => { - // The host serializes `ApiResult` so op/query errors - // (validation, geometry, unknown id) surface as the SAME - // structured `ApiError` the in-process executor produced. - let result: ApiResult = bincode::deserialize(&bytes) - .map_err(|e| ApiError::Transport(format!("receipt deserialize: {e}")))?; - result - } - PluginResponse::Error(msg) => Err(ApiError::Transport(msg)), - other => Err(ApiError::Transport(format!( - "unexpected DocApi response variant: {other:?}" - ))), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::facade::HasId; - use crate::ObjectId; - use ocs_plugin_api::host::{ - CadDocument, EntityType, ExtendedDataRecord, Handle, HostApi, - }; - use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; - use std::any::Any; - use std::sync::{Arc, Mutex}; - - #[derive(Clone)] - struct MockSender { - captured: Arc>>, - } - - impl PluginRequestSender for MockSender { - fn request(&self, req: PluginRequest) -> Result { - let tab_id = match &req { - PluginRequest::DocApiRequest { tab_id, .. } => *tab_id, - _ => 0, - }; - self.captured.lock().unwrap().push((tab_id, req)); - Ok(PluginResponse::DocApiResponse { - bytes: bincode::serialize(&crate::ApiResult::Ok(Receipt { - outcome: Some(crate::envelope::OpOutcome::NewId(ObjectId::from_u64(7))), - query_results: vec![], - new_revision: crate::revision::GeometryRevision(1), - })) - .unwrap(), - }) - } - } - - struct MockHost { - sender: Option, - } - - impl HostApi for MockHost { - fn tab_index(&self) -> usize { - 0 - } - fn document(&self) -> &CadDocument { - unimplemented!() - } - fn document_mut(&mut self) -> &mut CadDocument { - unimplemented!() - } - fn add_entity(&mut self, _entity: EntityType) -> Handle { - unimplemented!() - } - fn bump_geometry(&mut self) {} - fn push_undo(&mut self, _label: &str) {} - fn set_dirty(&mut self) {} - fn push_info(&mut self, _msg: &str) {} - fn push_output(&mut self, _msg: &str) {} - fn push_error(&mut self, _msg: &str) {} - fn start_interactive(&mut self, _command: Box) {} - fn read_record(&self, _handle: Handle, _app_name: &str) -> Option<&ExtendedDataRecord> { - None - } - fn write_record(&mut self, _handle: Handle, _record: ExtendedDataRecord) -> bool { - false - } - fn remove_record(&mut self, _handle: Handle, _app_name: &str) -> bool { - false - } - fn plugin_state_any(&self, _plugin_id: &str) -> Option<&(dyn Any + Send + Sync)> { - None - } - fn plugin_state_any_mut(&mut self, _plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)> { - None - } - fn ensure_plugin_state_any( - &mut self, - _plugin_id: &'static str, - _init: &mut dyn FnMut() -> Box, - ) -> &mut (dyn Any + Send + Sync) { - unimplemented!() - } - fn document_reader(&self) -> Box { - unimplemented!() - } - fn plugin_request_sender(&self) -> Option> { - self.sender.as_ref().map(|s| Box::new(s.clone()) as Box) - } - } - - #[test] - fn doc_api_for_host_builds_doc_api_and_routes_doc_api_request() { - let captured = Arc::new(Mutex::new(Vec::new())); - let host = MockHost { - sender: Some(MockSender { - captured: Arc::clone(&captured), - }), - }; - let doc_api = doc_api_for_host(&host).expect("host exposes a sender"); - let doc = doc_api.document(host.tab_id()); - let id = doc - .solids() - .create_cuboid([0.0; 3], [1.0; 3]) - .expect("mock returns a NewId receipt"); - assert_eq!(id.id(), ObjectId::from_u64(7)); - - let calls = captured.lock().unwrap(); - assert_eq!(calls.len(), 1); - let (tab_id, ref req) = &calls[0]; - assert_eq!(*tab_id, host.tab_id()); - match req { - PluginRequest::DocApiRequest { tab_id, bytes } => { - assert_eq!(*tab_id, host.tab_id()); - assert!(!bytes.is_empty()); - } - other => panic!("expected DocApiRequest, got {other:?}"), - } - } - - #[test] - fn doc_api_for_host_returns_none_without_sender() { - let host = MockHost { sender: None }; - assert!(doc_api_for_host(&host).is_none()); - } -} diff --git a/crates/ocs_doc_api/src/transport/mod.rs b/crates/ocs_doc_api/src/transport/mod.rs deleted file mode 100644 index 3a17f92e4..000000000 --- a/crates/ocs_doc_api/src/transport/mod.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! The transport boundary — the ONLY plugin↔host boundary. -//! -//! Portability contract (SPSC / flume / shared-memory safe): a transport impl -//! must (a) accept `&self` calls from many threads and serialize sends internally, -//! (b) demultiplex responses to the right caller by a correlation id, (c) -//! block-with-timeout, (d) expose liveness. This matches the existing V4 channel -//! shape, so swapping socket → shm changes only the impl, never the facade. - -#[cfg(feature = "host")] -pub mod inproc; -#[cfg(feature = "ipc")] -pub mod ipc; - -#[cfg(feature = "host")] -pub use inproc::InProcess; -#[cfg(feature = "ipc")] -pub use ipc::OcsPluginApiIpc; - -use crate::envelope::{DocApiEnvelope, Receipt}; -use crate::error::ApiResult; - -/// The only plugin↔host boundary. Object-centric: one method per unit of work — -/// a single write op, or a query batch. `&self` + `Send + Sync` so many entity -/// handles across many threads share one transport concurrently. -pub trait Transport: Send + Sync { - /// Apply `req` (ONE write op, or a query batch) and return its `Receipt`. - /// A write request carries exactly one `Operation`; a read request carries - /// one-or-more `Query` items (read-only batching is safe). Blocks up to an - /// internal timeout. Reentrant: safe to call concurrently from many threads. - fn apply(&self, req: DocApiEnvelope) -> ApiResult; - - /// Cheap host-liveness probe (shm has no socket-disconnect to detect a dead peer). - fn alive(&self) -> bool { - true - } -} diff --git a/crates/ocs_doc_api/src/validation.rs b/crates/ocs_doc_api/src/validation.rs deleted file mode 100644 index dc9ca9909..000000000 --- a/crates/ocs_doc_api/src/validation.rs +++ /dev/null @@ -1,299 +0,0 @@ -//! Reject invalid numeric inputs before the backend starts an undo operation. -use crate::{id::ObjectId, ops::*, ApiError, ApiResult}; - -fn finite(values: &[f64]) -> bool { - values.iter().all(|v| v.is_finite()) -} -fn positive(value: f64) -> bool { - value.is_finite() && value > 0.0 -} -fn direction(value: &[f64; 3]) -> bool { - finite(value) && value.iter().any(|v| *v != 0.0) -} -fn check(valid: bool, op: &'static str) -> ApiResult<()> { - if valid { - Ok(()) - } else { - Err(ApiError::validation( - op, - "invalid or nonfinite geometry input", - )) - } -} - -fn check_payload(valid: bool, op: &'static str, reason: &'static str) -> ApiResult<()> { - if valid { - Ok(()) - } else { - Err(ApiError::validation(op, reason)) - } -} - -fn valid_application_name(name: &str) -> bool { - !name.trim().is_empty() && !name.contains('\0') -} - -fn valid_xdata_record(app: &str, record: Option<&XDataRecord>) -> bool { - valid_application_name(app) - && record.is_none_or(|record| { - record.application_name == app - && record.values.iter().all(|value| match value { - XDataValue::String(value) - | XDataValue::ControlString(value) - | XDataValue::LayerName(value) => !value.contains('\0'), - XDataValue::Point3D(value) - | XDataValue::Position3D(value) - | XDataValue::Displacement3D(value) - | XDataValue::Direction3D(value) => finite(value), - XDataValue::Real(value) - | XDataValue::Distance(value) - | XDataValue::ScaleFactor(value) => value.is_finite(), - XDataValue::BinaryData(_) - | XDataValue::Handle(_) - | XDataValue::Integer16(_) - | XDataValue::Integer32(_) => true, - }) - }) -} - -fn valid_xrecord(spec: &XRecordSpec) -> bool { - !spec.name.trim().is_empty() - && !spec.name.contains('\0') - && spec.entries.len() <= BULK_ITEM_CAP - && spec.entries.iter().all(|entry| { - let code = entry.code; - match &entry.value { - XRecordValue::String(value) => { - matches!( - code, - 0..=4 - | 6..=9 - | 100..=102 - | 300..=309 - | 410..=419 - | 430..=439 - | 470..=479 - | 999 - | 1000..=1003 - ) && !value.contains('\0') - && value.encode_utf16().count() <= u16::MAX as usize - } - XRecordValue::Point3D(value) => { - matches!(code, 10..=37 | 110..=139 | 210..=269 | 1010..=1039 | 1043..=1069) - && finite(value) - } - XRecordValue::Double(value) => { - matches!(code, 38..=59 | 140..=149 | 460..=469 | 1040..=1042) - && value.is_finite() - } - XRecordValue::Int16(_) => { - matches!(code, 60..=79 | 170..=179 | 270..=279 | 370..=389 | 400..=409 | 1070) - } - XRecordValue::Int32(_) => matches!(code, 80..=99 | 420..=429 | 440..=459 | 1071), - XRecordValue::Int64(_) => matches!(code, 150..=169), - XRecordValue::Byte(_) => matches!(code, 280..=289), - XRecordValue::Bool(_) => matches!(code, 290..=299), - XRecordValue::Handle(_) => { - code < 0 || matches!(code, 5 | 105 | 320..=369 | 390..=399 | 480..=481 | 1005) - } - XRecordValue::Chunk(value) => { - matches!(code, 310..=319 | 1004) && value.len() <= u8::MAX as usize - } - } - }) -} - -pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> { - use Curve2Spec::*; - let valid = match spec { - Line { start, end, .. } => finite(start) && finite(end) && start != end, - Circle { centre, radius, .. } => finite(centre) && positive(*radius), - Point { position, .. } => finite(position), - Arc { - centre, - radius, - start_angle, - end_angle, - .. - } => finite(centre) && positive(*radius) && finite(&[*start_angle, *end_angle]), - Polyline { points, closed, .. } => { - points.len() >= if *closed { 3 } else { 2 } - && points.len() <= BULK_ITEM_CAP - && points.iter().all(|p| finite(p) && p[2] == points[0][2]) - } - Ellipse { - centre, - major_axis, - ratio, - start, - end, - .. - } => { - finite(centre) - && direction(major_axis) - && positive(*ratio) - && *ratio <= 1.0 - && finite(&[*start, *end]) - } - Spline { - degree, - control_points, - knots, - weights, - .. - } => { - *degree > 0 - && control_points.len() <= BULK_ITEM_CAP - && knots.len() <= BULK_ITEM_CAP - && weights.len() <= BULK_ITEM_CAP - && cadkernel::space::NurbsCurve3::new_strict( - *degree as usize, - control_points.clone(), - knots.clone(), - if weights.is_empty() { - vec![1.0; control_points.len()] - } else { - weights.clone() - }, - ) - .is_some() - } - Ray { - origin, - direction: d, - .. - } - | XLine { - origin, - direction: d, - .. - } => finite(origin) && direction(d), - }; - check(valid, "CreateCurve") -} - -pub(crate) fn operation(op: &Operation) -> ApiResult<()> { - use Operation::*; - let valid = match op { - CreateCurve(spec) => return curve(spec), - Extrude { direction: d, .. } => direction(d), - Revolve { - axis: (pivot, d), - angle, - .. - } => finite(pivot) && direction(d) && angle.is_finite() && *angle != 0.0, - AddVertex { point, .. } => finite(point), - CreateInsert(s) => { - finite(&s.insert_point) - && s.scale.is_finite() - && s.scale != 0.0 - && s.rotation.is_finite() - } - CreateViewport(s) => { - finite(&s.center) - && finite(&s.view_target) - && positive(s.width) - && positive(s.height) - && positive(s.view_height) - } - CreateText(s) => finite(&s.insertion_point) && positive(s.height) && s.rotation.is_finite(), - CreateMText(s) => finite(&s.insertion_point) && positive(s.height), - SetXData { - id, - application_name, - record, - } => { - return check_payload( - *id != ObjectId::NULL && valid_xdata_record(application_name, record.as_ref()), - "SetXData", - "invalid XDATA record", - ) - } - SetXDataMany(records) => { - return check_payload( - records.iter().all(|(id, app, record)| { - *id != ObjectId::NULL && valid_xdata_record(app, record.as_ref()) - }), - "SetXDataMany", - "invalid XDATA record", - ) - } - CreateXRecord(spec) => { - return check_payload(valid_xrecord(spec), "CreateXRecord", "invalid XRecord payload") - } - SetXRecord { id, spec } => { - return check_payload( - *id != ObjectId::NULL && valid_xrecord(spec), - "SetXRecord", - "invalid XRecord payload", - ) - } - CreateHatch(s) => { - (3..=BULK_ITEM_CAP).contains(&s.boundary.len()) && s.boundary.iter().all(|p| finite(p)) - } - CreateDimensionLinear(s) => { - finite(&s.first_point) && finite(&s.second_point) && finite(&s.definition_point) - } - CreateDimensionRadius(s) | CreateDimensionDiameter(s) => { - finite(&s.center) && finite(&s.point) - } - CreateDimensionAngular(s) | CreateDimensionAngular2Ln(s) => { - finite(&s.vertex) - && finite(&s.first_point) - && finite(&s.second_point) - && finite(&s.arc_location) - } - CreateAttributeDefinition(s) => { - finite(&s.insertion_point) && positive(s.height) && s.rotation.is_finite() - } - SetViewportView { - view_target, - view_height, - .. - } => finite(view_target) && positive(*view_height), - CreateRasterImage(s) => { - finite(&s.insertion_point) - && direction(&s.u_vector) - && direction(&s.v_vector) - && s.size.iter().all(|v| positive(*v)) - } - CreateTable(s) => { - finite(&s.insertion_point) - && !s.data.is_empty() - && !s.data[0].is_empty() - && s.data - .len() - .checked_mul(s.data[0].len()) - .is_some_and(|n| n <= BULK_ITEM_CAP) - && s.data.iter().all(|r| r.len() == s.data[0].len()) - } - _ => true, - }; - check(valid, op.op_name()) -} - -pub(crate) fn solid(spec: &SolidPrimitive) -> ApiResult<()> { - use SolidPrimitive::*; - let valid = match spec { - Cuboid { origin, size } | Wedge { origin, size } => { - finite(origin) && size.iter().all(|v| positive(*v)) - } - Sphere { centre, radius } => finite(centre) && positive(*radius), - Cylinder { - base, - radius, - height, - } - | Cone { - base, - radius, - height, - } => finite(base) && positive(*radius) && positive(*height), - Torus { - centre, - major_radius, - minor_radius, - } => finite(centre) && positive(*major_radius) && positive(*minor_radius), - }; - check(valid, "CreateSolid") -} diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs deleted file mode 100644 index d9059bbde..000000000 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ /dev/null @@ -1,1082 +0,0 @@ -//! Unit tests over an in-memory mock `DocApiBackend`: no host. -//! Covers the §9 examples: per-op atomicity, boolean erase_sources, OpGroup -//! compensation, bulk all-or-nothing, revision bumps, read-only query batching, -//! and stale-handle `UnknownId` semantics. - -use std::collections::HashMap; -use std::sync::Arc; - -use ocs_doc_api::backend::{DocApiBackend, KernelBody}; -use ocs_doc_api::{ - normalize_layer_name, Aabb, ApiError, ApiResult, Color, Curve2Spec, DocApi, EntityView, - GeometryErrorKind, GeometryRevision, HasId, LayerInfo, LineWeight, ObjectId, PlacementSpec, - XDataRecord, XDataValue, -}; - -/// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds and -/// the example mock — all three must answer curve bounds identically). -fn curve_bounds(spec: &Curve2Spec) -> Aabb { - match spec { - Curve2Spec::Line { start, end, .. } => Aabb { - min: [ - start[0].min(end[0]), - start[1].min(end[1]), - start[2].min(end[2]), - ], - max: [ - start[0].max(end[0]), - start[1].max(end[1]), - start[2].max(end[2]), - ], - }, - Curve2Spec::Circle { centre, radius, .. } - | Curve2Spec::Arc { centre, radius, .. } => Aabb { - min: [centre[0] - radius, centre[1] - radius, centre[2]], - max: [centre[0] + radius, centre[1] + radius, centre[2]], - }, - Curve2Spec::Point { position, .. } => Aabb { - min: *position, - max: *position, - }, - Curve2Spec::Polyline { points, .. } - | Curve2Spec::Spline { - control_points: points, - .. - } => { - let mut min = [f64::INFINITY; 3]; - let mut max = [f64::NEG_INFINITY; 3]; - for p in points { - for i in 0..3 { - min[i] = min[i].min(p[i]); - max[i] = max[i].max(p[i]); - } - } - Aabb { min, max } - } - Curve2Spec::Ellipse { - centre, major_axis, .. - } => { - let r = (major_axis[0] * major_axis[0] - + major_axis[1] * major_axis[1] - + major_axis[2] * major_axis[2]) - .sqrt(); - Aabb { - min: [centre[0] - r, centre[1] - r, centre[2] - r], - max: [centre[0] + r, centre[1] + r, centre[2] + r], - } - } - Curve2Spec::Ray { origin, .. } | Curve2Spec::XLine { origin, .. } => Aabb { - min: *origin, - max: *origin, - }, - } -} - -/// In-memory mock backend: HashMap + a tiny entity table. -/// Tracks undo steps + revision bumps to assert per-op semantics. -#[derive(Clone)] -struct MockBackend { - next_id: u64, - revision: u64, - undo_steps: u32, - bodies: HashMap, - kinds: HashMap, - /// Layer name assigned to each entity. - entity_layers: HashMap, - /// Stored layer table. - layers: HashMap, - /// Stored text content for Text/MText annotations. - text_values: HashMap, - /// Stored hatch boundary loops. - hatch_boundaries: HashMap>>, - /// Stored dimension measurements. - dimension_measurements: HashMap, - /// Stored insert attributes. - attributes_store: HashMap>, - /// Stored 2D curve specs so `bounds()` works on curve entities (aligned with - /// the example mock — both mocks must answer curve bounds the same way). - curves: HashMap, - /// Stored viewport views (target, height). - viewport_views: HashMap, - /// Stored XDATA records keyed by (entity id, application name). - xdata_store: HashMap<(ObjectId, String), ocs_doc_api::XDataRecord>, - /// Stored XRECORD payloads keyed by entity id. - xrecord_store: HashMap, -} - -impl Default for MockBackend { - fn default() -> Self { - let mut backend = Self { - next_id: 0, - revision: 0, - undo_steps: 0, - bodies: HashMap::new(), - kinds: HashMap::new(), - entity_layers: HashMap::new(), - layers: HashMap::new(), - text_values: HashMap::new(), - hatch_boundaries: HashMap::new(), - dimension_measurements: HashMap::new(), - attributes_store: HashMap::new(), - curves: HashMap::new(), - viewport_views: HashMap::new(), - xdata_store: HashMap::new(), - xrecord_store: HashMap::new(), - }; - backend - .layers - .insert("0".to_string(), ocs_doc_api::LayerInfo::new("0")); - backend - } -} - -impl MockBackend { - fn alloc(&mut self, kind: &str) -> ObjectId { - self.next_id += 1; - let id = ObjectId::from_u64(self.next_id); - self.kinds.insert(id, kind.to_string()); - id - } - fn body(&self, id: ObjectId) -> ApiResult<&KernelBody> { - self.bodies.get(&id).ok_or(ApiError::UnknownId(id)) - } - fn add_curve_only(&mut self, spec: &Curve2Spec) -> ApiResult { - let kind = match spec { - Curve2Spec::Line { .. } => "Line", - Curve2Spec::Circle { .. } => "Circle", - Curve2Spec::Polyline { .. } => "LwPolyline", - Curve2Spec::Point { .. } => "Point", - Curve2Spec::Arc { .. } => "Arc", - Curve2Spec::Ellipse { .. } => "Ellipse", - Curve2Spec::Spline { .. } => "Spline", - Curve2Spec::Ray { .. } => "Ray", - Curve2Spec::XLine { .. } => "XLine", - }; - let id = self.alloc(kind); - self.curves.insert(id, spec.clone()); - Ok(id) - } -} - -impl DocApiBackend for MockBackend { - fn resolve_body(&mut self, id: ObjectId) -> ApiResult { - self.bodies.get(&id).cloned().ok_or(ApiError::UnknownId(id)) - } - fn store_solid(&mut self, body: &KernelBody) -> ApiResult { - let id = self.alloc("Solid3D"); - self.bodies.insert(id, body.clone()); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> { - if self.bodies.insert(id, body.clone()).is_none() { - return Err(ApiError::UnknownId(id)); - } - Ok(()) - } - fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { - // Validate per-spec layers up front so the op is atomic. - for spec in specs { - if let Some(name) = spec.layer() { - self.lookup_layer(name).map_err(|e| ApiError::validation( - "CreateMany", - e.to_string(), - ))?; - } - } - let mut next = self.clone(); - let ids = specs - .iter() - .map(|spec| match spec { - ocs_doc_api::EntitySpec::Solid(spec) => { - next.store_solid(&ocs_doc_api::geom::make_solid(spec)?) - } - ocs_doc_api::EntitySpec::Curve(spec) => { - ocs_doc_api::convert::curve_spec_to_entity(spec)?; - next.add_curve(spec) - } - }) - .collect::>>()?; - next.push_undo("CreateMany"); - next.finalize_op(); - *self = next; - Ok(ids) - } - fn transform_many(&mut self, ids: &[ObjectId], placement: &PlacementSpec) -> ApiResult<()> { - let mut next = self.clone(); - for &id in ids { - next.transform_entity(id, placement)?; - } - next.push_undo("TransformMany"); - next.finalize_op(); - *self = next; - Ok(()) - } - fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - let layer = spec - .layer() - .map(|name| { - self.lookup_layer(name).map(|info| info.normalized_name()) - }) - .transpose()?; - let id = self.add_curve_only(spec)?; - self.entity_layers - .insert(id, layer.unwrap_or_else(|| "0".to_string())); - Ok(id) - } - - fn lookup_layer(&self, name: &str) -> ApiResult { - let norm = normalize_layer_name(name); - if norm.is_empty() { - return Err(ApiError::validation("LookupLayer", "empty layer name")); - } - self.layers - .get(&norm) - .cloned() - .ok_or_else(|| ApiError::validation("LookupLayer", format!("layer '{name}' does not exist"))) - } - fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { - if spec.block_name.is_empty() { - return Err(ApiError::validation("CreateInsert", "empty block name")); - } - let id = self.alloc("Insert"); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { - let id = self.alloc("Viewport"); - self.viewport_views - .insert(id, (spec.view_target, spec.view_height)); - Ok(id) - } - fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { - let id = self.alloc("Text"); - self.text_values.insert(id, spec.value.clone()); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult { - let id = self.alloc("MText"); - self.text_values.insert(id, spec.value.clone()); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { - if let std::collections::hash_map::Entry::Occupied(mut e) = self.text_values.entry(id) { - e.insert(value.to_string()); - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - fn text_content(&self, id: ObjectId) -> ApiResult { - self.text_values - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn add_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult { - let id = self.alloc("Hatch"); - self.hatch_boundaries - .insert(id, vec![spec.boundary.clone()]); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { - self.hatch_boundaries - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn add_dimension_linear( - &mut self, - spec: &ocs_doc_api::ops::DimensionSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - let d = ((spec.first_point[0] - spec.second_point[0]).powi(2) - + (spec.first_point[1] - spec.second_point[1]).powi(2) - + (spec.first_point[2] - spec.second_point[2]).powi(2)) - .sqrt(); - self.dimension_measurements.insert(id, d); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn add_dimension_radius( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - let d = ((spec.center[0] - spec.point[0]).powi(2) - + (spec.center[1] - spec.point[1]).powi(2) - + (spec.center[2] - spec.point[2]).powi(2)) - .sqrt(); - self.dimension_measurements.insert(id, d); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn add_dimension_diameter( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - let id = self.add_dimension_radius(spec)?; - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn add_dimension_angular( - &mut self, - _spec: &ocs_doc_api::ops::DimensionAngularSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - self.dimension_measurements.insert(id, 90.0); // mock: 90 degrees - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn dimension_measurement(&self, id: ObjectId) -> ApiResult { - self.dimension_measurements - .get(&id) - .copied() - .ok_or(ApiError::UnknownId(id)) - } - fn add_attribute_definition( - &mut self, - spec: &ocs_doc_api::ops::AttributeDefinitionSpec, - ) -> ApiResult { - if spec.tag.is_empty() { - return Err(ApiError::validation( - "CreateAttributeDefinition", - "empty tag", - )); - } - Ok(self.alloc("AttributeDefinition")) - } - fn add_dimension_angular2ln( - &mut self, - _spec: &ocs_doc_api::ops::DimensionAngularSpec, - ) -> ApiResult { - let id = self.alloc("Dimension"); - self.dimension_measurements.insert(id, 90.0); // mock: 90 degrees - Ok(id) - } - fn add_table(&mut self, spec: &ocs_doc_api::ops::TableSpec) -> ApiResult { - let cols = spec.data.first().map(|r| r.len()).unwrap_or(0); - if spec.data.is_empty() || cols == 0 || spec.data.iter().any(|r| r.len() != cols) { - return Err(ApiError::validation( - "CreateTable", - "table needs a non-empty rectangular grid", - )); - } - let id = self.alloc("Table"); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let attrs = self.attributes_store.entry(id).or_default(); - if let Some(a) = attrs.iter_mut().find(|(t, _)| t == tag) { - a.1 = value.to_string(); - } else { - attrs.push((tag.to_string(), value.to_string())); - } - Ok(()) - } - fn attributes(&self, id: ObjectId) -> ApiResult> { - self.attributes_store - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn block_entities(&self, _block_name: &str) -> ApiResult> { - Ok(Vec::new()) - } - fn add_raster_image( - &mut self, - _spec: &ocs_doc_api::ops::RasterImageSpec, - ) -> ApiResult { - let id = self.alloc("RasterImage"); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn loft( - &mut self, - sections: &[(cadkernel::space::Plane, Vec)], - ) -> ApiResult { - if sections.len() < 2 { - return Err(ApiError::validation("Loft", "loft needs >= 2 profiles")); - } - // Mock: store a unit cuboid as the loft result. - let body = cadkernel::brep::make::cuboid([0.0, 0.0, 0.0], [1.0; 3]) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "make cuboid"))?; - self.store_solid(&body) - } - fn set_viewport_view( - &mut self, - id: ObjectId, - view_target: [f64; 3], - view_height: f64, - ) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - self.viewport_views.insert(id, (view_target, view_height)); - Ok(()) - } - fn viewport_view(&self, id: ObjectId) -> ApiResult<([f64; 3], f64)> { - self.viewport_views - .get(&id) - .copied() - .ok_or(ApiError::UnknownId(id)) - } - fn set_xdata( - &mut self, - id: ObjectId, - application_name: &str, - record: Option<&ocs_doc_api::XDataRecord>, - ) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let key = (id, application_name.to_string()); - match record { - Some(r) => { - self.xdata_store.insert(key, r.clone()); - } - None => { - self.xdata_store.remove(&key); - } - } - Ok(()) - } - fn xdata( - &self, - id: ObjectId, - application_name: &str, - ) -> ApiResult> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - Ok(self - .xdata_store - .get(&(id, application_name.to_string())) - .cloned()) - } - fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult { - let id = self.alloc("XRecord"); - self.xrecord_store.insert(id, spec.clone()); - Ok(id) - } - fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "SetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - self.xrecord_store.insert(id, spec.clone()); - Ok(()) - } - fn xrecord(&self, id: ObjectId) -> ApiResult> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "GetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - Ok(self.xrecord_store.get(&id).cloned()) - } - fn create_layer(&mut self, info: &ocs_doc_api::LayerInfo) -> ApiResult<()> { - let name = normalize_layer_name(&info.name); - if name.is_empty() { - return Err(ApiError::validation("CreateLayer", "empty layer name")); - } - if self.layers.contains_key(&name) { - return Err(ApiError::validation( - "CreateLayer", - format!("layer '{name}' already exists"), - )); - } - let mut stored = info.clone(); - stored.name = name.clone(); - self.layers.insert(name, stored); - Ok(()) - } - fn update_layer(&mut self, name: &str, info: &ocs_doc_api::LayerInfo) -> ApiResult<()> { - let name_upper = normalize_layer_name(name); - if !self.layers.contains_key(&name_upper) { - return Err(ApiError::validation( - "UpdateLayer", - format!("layer '{name}' does not exist"), - )); - } - let mut stored = info.clone(); - stored.name = name_upper.clone(); - self.layers.insert(name_upper, stored); - Ok(()) - } - fn delete_layer(&mut self, name: &str) -> ApiResult<()> { - let name_upper = normalize_layer_name(name); - if name_upper == "0" { - return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0")); - } - if self - .entity_layers - .values() - .any(|l| normalize_layer_name(l) == name_upper) - { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name_upper}' still in use"), - )); - } - if self.layers.remove(&name_upper).is_none() { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name}' does not exist"), - )); - } - Ok(()) - } - fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let layer_upper = normalize_layer_name(layer); - let target = self - .layers - .get(&layer_upper) - .ok_or_else(|| { - ApiError::validation( - "SetEntityLayer", - format!("layer '{layer_upper}' does not exist"), - ) - })?; - if target.flags.locked { - return Err(ApiError::Unsupported(format!( - "entity {id:?} cannot be moved to locked layer '{layer_upper}'" - ))); - } - self.entity_layers.insert(id, layer_upper); - Ok(()) - } - fn layers(&self) -> ApiResult> { - let mut v: Vec<_> = self.layers.values().cloned().collect(); - v.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(v) - } - fn entity_layer(&self, id: ObjectId) -> ApiResult { - self.entity_layers - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn enumerate_entities( - &self, - kind: Option<&str>, - layer: Option<&str>, - include_bounds: bool, - ) -> ApiResult> { - let mut out = Vec::new(); - for (id, kind_name) in &self.kinds { - if let Some(k) = kind { - if !k.eq_ignore_ascii_case(kind_name) { - continue; - } - } - let entity_layer = self - .entity_layers - .get(id) - .cloned() - .unwrap_or_else(|| "0".to_string()); - if let Some(l) = layer { - if !l.eq_ignore_ascii_case(&entity_layer) { - continue; - } - } - let bounds = if include_bounds { - self.curves.get(id).map(curve_bounds) - } else { - None - }; - out.push(EntityView { - id: *id, - kind: kind_name.clone(), - layer: entity_layer, - bounds, - }); - } - Ok(out) - } - fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> { - let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; - match spec { - Curve2Spec::Point { position, .. } => Ok(*position), - _ => Err(ApiError::Unsupported( - "GetPointPosition is only for Point entities".into(), - )), - } - } - fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> { - let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; - match spec { - Curve2Spec::Line { start, end, .. } => Ok((*start, *end)), - _ => Err(ApiError::Unsupported( - "GetLineGeometry is only for Line entities".into(), - )), - } - } - fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { - if self.entity_exists(id) { - Ok(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - fn remove_entity(&mut self, id: ObjectId) -> ApiResult { - self.bodies.remove(&id); - self.xdata_store.retain(|(k, _), _| *k != id); - self.xrecord_store.remove(&id); - self.entity_layers.remove(&id); - Ok(self.kinds.remove(&id).is_some()) - } - fn get_entity(&mut self, id: ObjectId) -> ApiResult { - let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone(); - let bounds = self.bodies.get(&id).and_then(|b| { - cadkernel::brep::body_bounds(b).map(|bb| Aabb { - min: bb.min, - max: bb.max, - }) - }); - let layer = self - .entity_layers - .get(&id) - .cloned() - .unwrap_or_else(|| "0".to_string()); - Ok(EntityView { - id, - kind, - layer, - bounds, - }) - } - fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - if let Some(body) = self.bodies.get(&id).cloned() { - let place = cadkernel::brep::Placement { - x_axis: placement.x_axis, - y_axis: placement.y_axis, - z_axis: placement.z_axis, - origin: placement.origin, - }; - let moved = cadkernel::brep::transform(&body, &place).ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed") - })?; - self.bodies.insert(id, moved); - } - Ok(()) - } - fn profile_curves(&self, _id: ObjectId) -> ApiResult> { - Err(ApiError::Unsupported("profiles not mocked".into())) - } - fn bounds(&mut self, id: ObjectId) -> ApiResult { - // Solids via the kernel body; 2D curves from their stored spec (aligned - // with the example mock — both mocks answer curve bounds identically). - if let Some(spec) = self.curves.get(&id) { - return Ok(curve_bounds(spec)); - } - let body = self.body(id)?; - let bb = cadkernel::brep::body_bounds(body) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?; - Ok(Aabb { - min: bb.min, - max: bb.max, - }) - } - fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { - let body = self.body(id)?; - Ok(ocs_doc_api::geom::mesh_volume_centroid(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).1) - } - fn volume(&mut self, id: ObjectId) -> ApiResult { - let body = self.body(id)?; - Ok(ocs_doc_api::geom::mesh_volume_centroid(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).0) - } - fn entity_exists(&self, id: ObjectId) -> bool { - self.kinds.contains_key(&id) - } - fn object_exists(&self, id: ObjectId) -> bool { - self.xrecord_store.contains_key(&id) - } - fn revision(&self) -> GeometryRevision { - GeometryRevision(self.revision) - } - fn push_undo(&mut self, _label: &str) {} - fn finalize_op(&mut self) { - self.undo_steps += 1; - self.revision += 1; - } -} - -fn api() -> (DocApi, Arc>) { - let tp = Arc::new(ocs_doc_api::transport::InProcess::new( - MockBackend::default(), - )); - (DocApi::connect(tp.clone(), 0), tp) -} - -#[test] -fn cuboid_intersect_cuboid_atomic_ops_and_revision() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let mut grp = ocs_doc_api::OpGroup::new(); - - let rev0 = doc.revision().unwrap(); - // Two overlapping boxes — the kernel's own boolean test case (boolean.rs pair()). - let block = grp - .track( - doc.solids() - .create_cuboid([0.0, 0.0, 0.0], [10.0, 10.0, 10.0]), - ) - .unwrap(); - let other = grp - .track( - doc.solids() - .create_cuboid([5.0, 5.0, 5.0], [10.0, 10.0, 10.0]), - ) - .unwrap(); - assert_eq!(doc.revision().unwrap().as_u64(), rev0.as_u64() + 2); - - let lens = match block.intersect(&other) { - Ok(l) => { - grp.commit(); - l - } - Err(e) => panic!("intersect failed: {e}"), - }; - assert_eq!(doc.revision().unwrap().as_u64(), rev0.as_u64() + 3); - - let res = doc - .query_batch(|q| { - q.bounds(&lens); - q.volume(&lens); - }) - .unwrap(); - let bb = res.bounds(0).unwrap(); - let vol = res.volume(1).unwrap(); - // Intersection of [0,10]³ ∩ [5,15]³ = [5,10]³ → bounds + volume 125. - assert!( - (bb.min[0] - 5.0).abs() < 1e-6 && (bb.max[0] - 10.0).abs() < 1e-6, - "bounds {bb:?}" - ); - assert!(vol > 0.0, "intersection volume must be positive"); -} - -#[test] -fn boolean_erase_sources_removes_second_solid() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let a = doc - .solids() - .create_cuboid([0.0, 0.0, 0.0], [10.0; 3]) - .unwrap(); - let b = doc - .solids() - .create_cuboid([5.0, 5.0, 5.0], [10.0; 3]) - .unwrap(); - let result = a.union(&b).unwrap(); - let backend = tp.backend(); - assert!(backend.entity_exists(a.id())); - assert!(!backend.entity_exists(b.id())); - assert_eq!(result.id(), a.id()); -} - -#[test] -fn stale_handle_get_returns_error() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let ghost = ObjectId::from_u64(999_999); - assert!(doc.entities().get(ghost).is_err()); -} - -#[test] -fn opgroup_compensate_deletes_created() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let mut grp = ocs_doc_api::OpGroup::new(); - let a = grp - .track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])) - .unwrap(); - let b = grp - .track(doc.solids().create_sphere([5.0, 5.0, 5.0], 4.0)) - .unwrap(); - grp.compensate(&doc).unwrap(); - let backend = tp.backend(); - assert!(!backend.entity_exists(a.id())); - assert!(!backend.entity_exists(b.id())); -} - -#[test] -fn bulk_create_many_one_undo_step() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let rev0 = doc.revision().unwrap().as_u64(); - let coords: Vec<[f64; 3]> = (0..1000).map(|i| [i as f64, 0.0, 0.0]).collect(); - let pts = doc.curves().create_points(&coords).unwrap(); - assert_eq!(pts.len(), 1000); - let backend = tp.backend(); - assert_eq!(backend.revision, rev0 + 1); - assert_eq!(backend.undo_steps, 1); -} - -#[test] -fn transform_many_with_stale_id_fails_and_applies_nothing() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let a = doc - .solids() - .create_cuboid([0.0, 0.0, 0.0], [10.0; 3]) - .unwrap(); - let rev_before = doc.revision().unwrap().as_u64(); - let stale = ObjectId::from_u64(424_242); - let err = doc - .entities() - .transform_many(&[a.id(), stale], PlacementSpec::at([1.0, 0.0, 0.0])) - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); - let backend = tp.backend(); - assert_eq!(backend.revision, rev_before); -} - -#[test] -fn assert_revision_guard() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let rev = doc.revision().unwrap(); - doc.assert_revision(rev).unwrap(); - doc.solids() - .create_cuboid([0.0, 0.0, 0.0], [1.0; 3]) - .unwrap(); - assert!(doc.assert_revision(rev).is_err()); -} - -#[test] -fn failed_boolean_leaves_inputs_live_and_no_undo() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let a = doc - .solids() - .create_cuboid([0.0, 0.0, 0.0], [10.0; 3]) - .unwrap(); - // `b` is stale: boolean fails before push_undo -> no mutation, no undo step. - let stale_solid = { - // Build a Solid handle to a non-existent id via Entity downcast path. - // Here we just call intersect against a deleted id. - let tmp = doc.solids().create_sphere([5.0, 5.0, 5.0], 4.0).unwrap(); - tmp.delete().unwrap(); - tmp - }; - let rev_before = doc.revision().unwrap().as_u64(); - assert!(a.intersect(&stale_solid).is_err()); - let backend = tp.backend(); - // `a` is still live; no undo recorded for the failed boolean. - assert!(backend.entity_exists(a.id())); - assert_eq!(backend.revision, rev_before); -} - -#[test] -fn typed_handles_reject_foreign_sessions_and_tabs() { - let first = DocApi::in_process(MockBackend::default(), 10); - let second = DocApi::in_process(MockBackend::default(), 10); - let doc = first.document(10); - let foreign = second - .document(10) - .curves() - .create_circle([0.0; 3], 2.0) - .unwrap(); - assert!(doc.solids().extrude(&foreign, [0.0, 0.0, 2.0]).is_err()); - assert!(doc - .solids() - .loft(&[foreign.clone(), foreign.clone()]) - .is_err()); - assert!(doc.query_batch(|q| q.bounds(&foreign)).is_err()); - let mut group = ocs_doc_api::OpGroup::new(); - group.track(Ok(foreign)).unwrap(); - assert!(group.compensate(&doc).is_err()); - assert!(first.document(11).curves().create_point([0.0; 3]).is_err()); -} - -#[test] -fn doc_api_layer_crud_via_facade_and_entity_assignment() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - - let mut info = LayerInfo::new("Walls"); - info.color = Color::Index(1); - info.line_weight = LineWeight::Value(25); - doc.create_layer(&info).unwrap(); - - let layers = doc.layers().unwrap(); - assert!(layers.iter().any(|l| l.name == "WALLS")); - - let mut updated = info.clone(); - updated.color = Color::Index(2); - doc.update_layer("walls", &updated).unwrap(); - - let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); - line.set_layer("Walls").unwrap(); - assert_eq!(line.layer().unwrap(), "WALLS"); - - let err = doc.delete_layer("Walls").unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); - - let other = LayerInfo::new("Other"); - doc.create_layer(&other).unwrap(); - line.set_layer("Other").unwrap(); - doc.delete_layer("Walls").unwrap(); - assert!(!doc.layers().unwrap().iter().any(|l| l.name == "WALLS")); -} - -#[test] -fn set_xdata_many_is_atomic_and_assigns_records() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); - let circle = doc.curves().create_circle([0.0; 3], 1.0).unwrap(); - - let record1 = XDataRecord { - application_name: "APP1".to_string(), - values: vec![XDataValue::Integer32(42)], - }; - let record2 = XDataRecord { - application_name: "APP2".to_string(), - values: vec![XDataValue::Real(3.14)], - }; - doc.set_xdata_many([ - (line.id(), "APP1", Some(record1.clone())), - (circle.id(), "APP2", Some(record2.clone())), - ]) - .unwrap(); - - let r1 = doc.entities().get(line.id()).unwrap().xdata("APP1").unwrap().unwrap(); - assert_eq!(r1.values, vec![XDataValue::Integer32(42)]); - let r2 = doc.entities().get(circle.id()).unwrap().xdata("APP2").unwrap().unwrap(); - assert_eq!(r2.values, vec![XDataValue::Real(3.14)]); -} - -#[test] -fn set_xdata_many_with_unknown_id_fails_atomically() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); - let stale = ObjectId::from_u64(999_999); - - let record = XDataRecord { - application_name: "APP1".to_string(), - values: vec![XDataValue::Integer32(42)], - }; - let err = doc - .set_xdata_many([ - (line.id(), "APP1", Some(record.clone())), - (stale, "APP2", Some(record)), - ]) - .unwrap_err(); - assert!(matches!(err, ApiError::UnknownId(..) | ApiError::Validation { .. })); - assert!(doc.entities().get(line.id()).unwrap().xdata("APP1").unwrap().is_none()); -} - -#[test] -fn xdata_rejects_a_mismatched_record_name() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); - let entity = doc.entities().get(line.id()).unwrap(); - let result = entity.set_xdata( - "EXPECTED", - Some(XDataRecord { - application_name: "OTHER".into(), - values: vec![], - }), - ); - assert!(matches!(result, Err(ApiError::Validation { .. }))); - assert!(entity.xdata("EXPECTED").unwrap().is_none()); -} - -#[test] -fn create_xrecord_returns_the_typed_handle() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let spec = ocs_doc_api::XRecordSpec { - name: "PLUGIN_DATA".into(), - cloning_flags: ocs_doc_api::XRecordCloningFlags::NotApplicable, - entries: vec![ocs_doc_api::XRecordEntry { - code: 1, - value: ocs_doc_api::XRecordValue::String("value".into()), - }], - }; - let record = doc.entities().create_xrecord(&spec).unwrap(); - assert_eq!(record.payload().unwrap(), spec); -} - -#[test] -fn create_many_applies_per_curve_layer() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - doc.create_layer(&LayerInfo::new("Walls")).unwrap(); - - let ids = doc - .entities() - .create_many(&[ - ocs_doc_api::EntitySpec::Curve(Curve2Spec::Line { - start: [0.0; 3], - end: [1.0; 3], - layer: Some("Walls".to_string()), - }), - ocs_doc_api::EntitySpec::Curve(Curve2Spec::Point { - position: [2.0; 3], - layer: None, - }), - ]) - .unwrap(); - - assert_eq!(ids[0].layer().unwrap(), "WALLS"); - assert_eq!(ids[1].layer().unwrap(), "0"); -} - -#[test] -fn create_many_rejects_missing_layer_before_mutating() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let rev0 = doc.revision().unwrap().as_u64(); - - let err = doc - .entities() - .create_many(&[ - ocs_doc_api::EntitySpec::Curve(Curve2Spec::Line { - start: [0.0; 3], - end: [1.0; 3], - layer: Some("Missing".to_string()), - }), - ocs_doc_api::EntitySpec::Curve(Curve2Spec::Point { - position: [2.0; 3], - layer: None, - }), - ]) - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); - - let backend = tp.backend(); - assert_eq!(backend.revision, rev0); - assert_eq!(backend.next_id, 0); -} - -#[test] -fn create_line_on_layer_requires_existing_layer() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let err = doc - .curves() - .create_line_on_layer([0.0; 3], [1.0; 3], "Missing") - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); -} diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs deleted file mode 100644 index d7fc6c3dd..000000000 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ /dev/null @@ -1,416 +0,0 @@ -//! Spec↔wire consistency tests (enforces the codegen contract the headers -//! promise): every `op`/`query` name in `spec/entities.toml` maps to an existing -//! `Operation`/`Query` variant, and the wire enums are append-only relative to a -//! recorded baseline (bincode discriminant stability). - -use std::collections::BTreeSet; - -use serde::Deserialize; - -#[derive(Deserialize)] -struct Spec { - #[serde(default)] - family: Vec, -} -#[derive(Deserialize)] -struct Family { - #[serde(default)] - handle: Option, - #[serde(default)] - constructor: Vec, - #[serde(default)] - method: Vec, -} -#[derive(Deserialize)] -struct MethodRef { - #[serde(default)] - op: Option, - #[serde(default)] - query: Option, -} - -fn spec() -> Spec { - let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/spec/entities.toml")) - .expect("read spec/entities.toml"); - toml::from_str(&src).expect("parse spec/entities.toml") -} - -/// The variant names of the `Operation` enum, in declaration order. -fn operation_variants() -> Vec { - use ocs_doc_api::ops::{ - BoolOp, Curve2Spec, InsertSpec, LayerInfo, Operation, PlacementSpec, SolidPrimitive, - ViewportSpec, - }; - use ocs_doc_api::ObjectId; - let _marker: Vec = vec![ - Operation::CreateSolid(SolidPrimitive::Sphere { - centre: [0.0; 3], - radius: 1.0, - }), - Operation::CreateCurve(Curve2Spec::Point { - position: [0.0; 3], - layer: None, - }), - Operation::Extrude { - profile: ObjectId::from_u64(0), - direction: [0.0; 3], - }, - Operation::Revolve { - profile: ObjectId::from_u64(0), - axis: ([0.0; 3], [0.0; 3]), - angle: 0.0, - }, - Operation::Transform { - id: ObjectId::from_u64(0), - placement: PlacementSpec::IDENTITY, - }, - Operation::SolidBoolean { - op: BoolOp::Union, - a: ObjectId::from_u64(0), - b: ObjectId::from_u64(0), - erase_sources: true, - }, - Operation::AddVertex { - id: ObjectId::from_u64(0), - at: 0, - point: [0.0; 3], - }, - Operation::Delete { - id: ObjectId::from_u64(0), - }, - Operation::CreateMany(vec![]), - Operation::TransformMany { - ids: vec![], - placement: PlacementSpec::IDENTITY, - }, - Operation::DeleteMany(vec![]), - Operation::CreateInsert(InsertSpec { - block_name: String::new(), - insert_point: [0.0; 3], - scale: 1.0, - rotation: 0.0, - }), - Operation::CreateViewport(ViewportSpec { - center: [0.0; 3], - width: 0.0, - height: 0.0, - view_target: [0.0; 3], - view_height: 0.0, - }), - Operation::CreateText(ocs_doc_api::ops::TextSpec { - value: String::new(), - insertion_point: [0.0; 3], - height: 0.0, - rotation: 0.0, - }), - Operation::CreateMText(ocs_doc_api::ops::MTextSpec { - value: String::new(), - insertion_point: [0.0; 3], - height: 0.0, - }), - Operation::SetTextContent { - id: ObjectId::from_u64(0), - value: String::new(), - }, - Operation::CreateHatch(ocs_doc_api::ops::HatchSpec { - boundary: vec![], - solid: true, - }), - Operation::CreateDimensionLinear(ocs_doc_api::ops::DimensionSpec { - first_point: [0.0; 3], - second_point: [0.0; 3], - definition_point: [0.0; 3], - }), - Operation::SetAttribute { - id: ObjectId::from_u64(0), - tag: String::new(), - value: String::new(), - }, - Operation::SetViewportView { - id: ObjectId::from_u64(0), - view_target: [0.0; 3], - view_height: 0.0, - }, - Operation::CreateRasterImage(ocs_doc_api::ops::RasterImageSpec { - file_path: String::new(), - insertion_point: [0.0; 3], - u_vector: [0.0; 3], - v_vector: [0.0; 3], - size: [0.0; 2], - }), - Operation::Loft { profiles: vec![] }, - Operation::CreateDimensionRadius(ocs_doc_api::ops::DimensionRadialSpec { - center: [0.0; 3], - point: [0.0; 3], - }), - Operation::CreateDimensionDiameter(ocs_doc_api::ops::DimensionRadialSpec { - center: [0.0; 3], - point: [0.0; 3], - }), - Operation::CreateDimensionAngular(ocs_doc_api::ops::DimensionAngularSpec { - vertex: [0.0; 3], - first_point: [0.0; 3], - second_point: [0.0; 3], - arc_location: [0.0; 3], - }), - Operation::CreateAttributeDefinition(ocs_doc_api::ops::AttributeDefinitionSpec { - tag: String::new(), - prompt: String::new(), - default_value: String::new(), - insertion_point: [0.0; 3], - height: 0.0, - rotation: 0.0, - }), - Operation::CreateTable(ocs_doc_api::ops::TableSpec { - insertion_point: [0.0; 3], - data: vec![], - }), - Operation::CreateDimensionAngular2Ln(ocs_doc_api::ops::DimensionAngularSpec { - vertex: [0.0; 3], - first_point: [0.0; 3], - second_point: [0.0; 3], - arc_location: [0.0; 3], - }), - Operation::SetXData { - id: ObjectId::from_u64(0), - application_name: String::new(), - record: None, - }, - Operation::SetXDataMany(vec![]), - Operation::CreateXRecord(ocs_doc_api::ops::XRecordSpec { - name: String::new(), - cloning_flags: ocs_doc_api::ops::XRecordCloningFlags::NotApplicable, - entries: vec![], - }), - Operation::SetXRecord { - id: ObjectId::from_u64(0), - spec: ocs_doc_api::ops::XRecordSpec { - name: String::new(), - cloning_flags: ocs_doc_api::ops::XRecordCloningFlags::NotApplicable, - entries: vec![], - }, - }, - Operation::CreateLayer(LayerInfo::new("LAYER")), - Operation::UpdateLayer { - name: String::new(), - info: LayerInfo::new("LAYER"), - }, - Operation::DeleteLayer { - name: String::new(), - }, - Operation::SetEntityLayer { - id: ObjectId::from_u64(0), - layer: String::new(), - }, - ]; - _marker - .iter() - .enumerate() - .map(|(index, op)| { - let wire = bincode::serialize(op).unwrap(); - assert_eq!( - u32::from_le_bytes(wire[..4].try_into().unwrap()) as usize, - index - ); - let dbg = format!("{:?}", op); - dbg.split(['(', ' ', '{']) - .next() - .unwrap_or("") - .to_string() - }) - .collect() -} - -/// The variant names of the `Query` enum, in declaration order. -fn query_variants() -> Vec { - use ocs_doc_api::query::Query; - use ocs_doc_api::ObjectId; - let marker: Vec = vec![ - Query::GetEntity { - id: ObjectId::from_u64(0), - }, - Query::GetBounds { - id: ObjectId::from_u64(0), - }, - Query::GetCentroid { - id: ObjectId::from_u64(0), - }, - Query::GetVolume { - id: ObjectId::from_u64(0), - }, - Query::GetIntersects { - a: ObjectId::from_u64(0), - b: ObjectId::from_u64(0), - }, - Query::GetGeometryRevision, - Query::GetTextContent { - id: ObjectId::from_u64(0), - }, - Query::GetHatchBoundary { - id: ObjectId::from_u64(0), - }, - Query::GetDimensionMeasurement { - id: ObjectId::from_u64(0), - }, - Query::GetAttributes { - id: ObjectId::from_u64(0), - }, - Query::GetBlockEntities { - block_name: String::new(), - }, - Query::GetViewportView { - id: ObjectId::from_u64(0), - }, - Query::GetXData { - id: ObjectId::from_u64(0), - application_name: String::new(), - }, - Query::GetXRecord { - id: ObjectId::from_u64(0), - }, - Query::ListLayers, - Query::GetEntityLayer { - id: ObjectId::from_u64(0), - }, - ]; - marker - .iter() - .enumerate() - .map(|(index, q)| { - let wire = bincode::serialize(q).unwrap(); - assert_eq!( - u32::from_le_bytes(wire[..4].try_into().unwrap()) as usize, - index - ); - let dbg = format!("{:?}", q); - dbg.split(['(', ' ', '{']) - .next() - .unwrap_or("") - .to_string() - }) - .collect() -} - -#[test] -fn every_spec_op_and_query_maps_to_an_enum_variant() { - let spec = spec(); - let op_names: BTreeSet = operation_variants().into_iter().collect(); - let query_names: BTreeSet = query_variants().into_iter().collect(); - - let mut missing = Vec::new(); - for fam in &spec.family { - for m in fam.constructor.iter().chain(fam.method.iter()) { - if let Some(op) = &m.op { - if !op_names.contains(op) { - missing.push(format!( - "Operation::{op} (family {})", - fam.handle.as_deref().unwrap_or("?") - )); - } - } - if let Some(q) = &m.query { - if !query_names.contains(q) { - missing.push(format!( - "Query::{q} (family {})", - fam.handle.as_deref().unwrap_or("?") - )); - } - } - } - } - assert!( - missing.is_empty(), - "spec references ops/queries with no enum variant: {missing:?}" - ); -} - -/// Recorded baseline of the `Operation` variant order (append-only contract). -/// New variants must be APPENDED after these; none may be removed or reordered. -const OPERATION_BASELINE: &[&str] = &[ - "CreateSolid", - "CreateCurve", - "Extrude", - "Revolve", - "Transform", - "SolidBoolean", - "AddVertex", - "Delete", - "CreateMany", - "TransformMany", - "DeleteMany", - "CreateInsert", - "CreateViewport", - "CreateText", - "CreateMText", - "SetTextContent", - "CreateHatch", - "CreateDimensionLinear", - "SetAttribute", - "SetViewportView", - "CreateRasterImage", - "Loft", - "CreateDimensionRadius", - "CreateDimensionDiameter", - "CreateDimensionAngular", - "CreateAttributeDefinition", - "CreateTable", - "CreateDimensionAngular2Ln", - "SetXData", - "SetXDataMany", - "CreateXRecord", - "SetXRecord", - "CreateLayer", - "UpdateLayer", - "DeleteLayer", - "SetEntityLayer", -]; - -/// Recorded baseline of the `Query` variant order. -const QUERY_BASELINE: &[&str] = &[ - "GetEntity", - "GetBounds", - "GetCentroid", - "GetVolume", - "GetIntersects", - "GetGeometryRevision", - "GetTextContent", - "GetHatchBoundary", - "GetDimensionMeasurement", - "GetAttributes", - "GetBlockEntities", - "GetViewportView", - "GetXData", - "GetXRecord", - "ListLayers", - "GetEntityLayer", -]; - -#[test] -fn operation_enum_is_append_only_vs_baseline() { - let current = operation_variants(); - assert!( - current.len() >= OPERATION_BASELINE.len(), - "Operation enum shrank: {current:?} < baseline {OPERATION_BASELINE:?}" - ); - for (i, name) in OPERATION_BASELINE.iter().enumerate() { - assert_eq!( - current[i], *name, - "Operation variant {i} changed (append-only violation): {current:?}" - ); - } -} - -#[test] -fn query_enum_is_append_only_vs_baseline() { - let current = query_variants(); - assert!( - current.len() >= QUERY_BASELINE.len(), - "Query enum shrank: {current:?} < baseline {QUERY_BASELINE:?}" - ); - for (i, name) in QUERY_BASELINE.iter().enumerate() { - assert_eq!( - current[i], *name, - "Query variant {i} changed (append-only violation): {current:?}" - ); - } -} diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 2433e9c6b..7adc10b53 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "2f2cd25832db298524fb5eb36ced5a438a877e95", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -38,7 +38,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "2f2cd25832db298524fb5eb36ced5a438a877e95", features = ["serde"] } [dev-dependencies] serde_json = "1" 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/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index dbd456778..8b718cda5 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "2f2cd25832db298524fb5eb36ced5a438a877e95", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml index 68b944b56..bb15cc159 100644 --- a/crates/plugin-template-api2/Cargo.toml +++ b/crates/plugin-template-api2/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "2f2cd25832db298524fb5eb36ced5a438a877e95", features = ["serde"] } 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 344fa9b04..d7657a1ac 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()); @@ -3206,64 +3954,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 @@ -4457,7 +5167,41 @@ impl OpenCADStudio { self.command_line .push_error(crate::t!("HATCHEDIT: hatch entity not found.").as_ref()); } else { - use crate::command::HatchEditOperation; + use crate::command::{CadCommand, HatchEditOperation}; + if matches!(&operation,HatchEditOperation::BeginAssociate) { + let associative=matches!(self.tabs[i].scene.document.get_entity(handle),Some(acadrust::EntityType::Hatch(h)) if h.is_associative); + if associative { + self.command_line.push_info("HATCHEDIT: hatch is already associative."); + self.tabs[i].active_cmd=None; + }else{ + let plane=match self.tabs[i].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Hatch(h))=>{ + let storage=crate::entities::curve::ocs_plane(h.normal,h.elevation); + crate::command::WorkingPlane::new(glam::DVec3::from_array(storage.origin),glam::DVec3::from_array(storage.x_axis),glam::DVec3::from_array(storage.y_axis)) + }, + _=>self.tabs[i].ucs_xform().working_plane(), + }; + let mut sources=self.tabs[i].scene.boundary_sources_on_plane(plane,1e-6); + sources.remove(&handle); + let command=crate::modules::draw::draw::hatchedit::HatcheditCommand::for_association(handle,name,scale,angle,plane,sources); + self.tabs[i].scene.deselect_all(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd=Some(Box::new(command)); + } + return Task::none(); + } + if let HatchEditOperation::DrawOrderBoundary{above}=&operation { + let references:Vec<_>=match self.tabs[i].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Hatch(h))=>h.paths.iter().flat_map(|p|p.boundary_handles.iter()) + .filter(|h|self.tabs[i].scene.document.get_entity(**h).is_some()).map(|h|format!("{:X}",h.value())).collect(), + _=>Vec::new(), + }; + self.tabs[i].active_cmd=None; + if references.is_empty(){self.command_line.push_info("HATCHEDIT: no associated boundary objects.");return Task::none();} + self.tabs[i].scene.deselect_all();self.tabs[i].scene.select_entity(handle,false); + let command=format!("DRAWORDER {} {}",if *above{"ABOVE"}else{"UNDER"},references.join(" ")); + return self.dispatch_view(&command,i).unwrap_or_else(Task::none); + } if matches!( &operation, HatchEditOperation::DrawOrderFront | HatchEditOperation::DrawOrderBack @@ -4474,12 +5218,38 @@ impl OpenCADStudio { } self.push_undo_snapshot(i, "HATCHEDIT"); match operation { + HatchEditOperation::Appearance { color, layer, transparency } => { + let layer=layer.map(|name|if name=="."{self.tabs[i].active_layer.clone()}else{name}); + if layer.as_ref().is_some_and(|name|self.tabs[i].scene.document.layers.get(name).is_none()) { + self.discard_last_undo_entry(i); + self.command_line.push_error("HATCHEDIT: layer not found."); + return Task::none(); + } + if let Some(entity)=self.tabs[i].scene.document.get_entity_mut(handle) { + let common=entity.common_mut(); + if let Some(value)=color {common.color=value;common.color_name=None;common.color_book_handle=None;} + if let Some(value)=layer {common.layer=value;} + if let Some(value)=transparency {common.transparency=value;} + } + self.tabs[i].scene.bump_entities(&[(handle,crate::scene::ChangeKind::Modified)]); + self.refresh_properties(); + } HatchEditOperation::Update { origin, + store_origin, disassociate, style, annotative, } => { + if store_origin { + if let Some((x, y)) = origin { + if !self.tabs[i].scene.document.set_hatch_origin([x, y]) { + self.discard_last_undo_entry(i); + self.command_line.push_error("HATCHEDIT: cannot store default origin."); + return Task::none(); + } + } + } if let Some(acadrust::EntityType::Hatch(hatch)) = self.tabs[i].scene.document.get_entity_mut(handle) { @@ -4487,11 +5257,6 @@ impl OpenCADStudio { if let Some(entry) = crate::scene::model::hatch_patterns::find(&name) { - let old_origin = hatch - .pattern - .lines - .first() - .map(|line| line.base_point); let mut pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry); crate::entities::hatch::scale_pattern_geometry( &mut pattern, @@ -4501,16 +5266,8 @@ impl OpenCADStudio { &mut pattern, (angle as f64).to_radians(), ); - if let (Some(old), Some(new)) = ( - old_origin, - pattern.lines.first().map(|line| line.base_point), - ) { - crate::entities::hatch::translate_pattern_geometry( - &mut pattern, - old.x - new.x, - old.y - new.y, - ); - } + let origin = hatch.pattern_origin(); + crate::entities::hatch::translate_pattern_geometry(&mut pattern, origin.x, origin.y); hatch.pattern = pattern; hatch.is_solid = matches!( entry.gpu, @@ -4524,30 +5281,16 @@ impl OpenCADStudio { let requested_scale = scale.max(1.0e-6) as f64; if hatch.pattern_scale > 1.0e-12 { let factor = requested_scale / hatch.pattern_scale; - crate::entities::hatch::scale_pattern_geometry( - &mut hatch.pattern, - factor, - ); + hatch.scale_pattern_about_origin(factor); } let requested_angle = (angle as f64).to_radians(); let delta = requested_angle - hatch.pattern_angle; - crate::entities::hatch::rotate_pattern_geometry( - &mut hatch.pattern, - delta, - ); + hatch.rotate_pattern_about_origin(delta); } hatch.pattern_scale = scale.max(1.0e-6) as f64; hatch.pattern_angle = (angle as f64).to_radians(); if let Some((x, y)) = origin { - if let Some(current) = - hatch.pattern.lines.first().map(|line| line.base_point) - { - crate::entities::hatch::translate_pattern_geometry( - &mut hatch.pattern, - x - current.x, - y - current.y, - ); - } + hatch.set_pattern_origin(acadrust::types::Vector2::new(x, y)); } if disassociate { for path in &mut hatch.paths { @@ -4593,7 +5336,15 @@ impl OpenCADStudio { .scene .edit_hatch_boundary_handles(handle, &handles, false); } - HatchEditOperation::RecreateBoundary => { + HatchEditOperation::AssociatePaths(paths) => { + if paths.is_empty()||paths.iter().any(|path|path.boundary_handles.is_empty()||path.boundary_handles.iter().any(|source|self.tabs[i].scene.document.get_entity(*source).is_none())) { + self.discard_last_undo_entry(i); + self.command_line.push_error("HATCHEDIT: associated boundary is no longer available."); + return Task::none(); + } + self.tabs[i].scene.replace_hatch_association(handle,paths); + } + HatchEditOperation::RecreateBoundary { associate, region } => { let source = self.tabs[i].scene.document.get_entity(handle).cloned(); if let Some(acadrust::EntityType::Hatch(source)) = source { let storage = crate::entities::curve::ocs_plane( @@ -4605,25 +5356,74 @@ impl OpenCADStudio { glam::DVec3::from_array(storage.x_axis), glam::DVec3::from_array(storage.y_axis), ); - let rings = crate::scene::hatch_boundary_rings(&source); - let entities = crate::scene::boundary_entities(&rings, plane); - let mut handles = Vec::new(); - for entity in entities { + let (entities, path_groups) = if region { + let loops = source.paths.iter().map(|path| { + path.edges.iter().map(crate::entities::hatch::edge_curve).collect::>>() + }).collect::>>(); + let Some(loops) = loops.filter(|loops| !loops.is_empty() && loops.iter().all(|ring| !ring.is_empty())) else { + self.discard_last_undo_entry(i); + self.command_line.push_error("HATCHEDIT: boundary cannot form a region."); + return Task::none(); + }; + let contained = loops.iter().enumerate().map(|(inner, ring)| { + let seed = ring[0].point_at(0.0); + loops.iter().enumerate().map(|(outer, boundary)| { + inner != outer && cadkernel::geom2d::contains(boundary, seed, cadkernel::geom2d::Tolerance::new(1e-6)) + }).collect::>() + }).collect::>(); + let depths = contained.iter().map(|row| row.iter().filter(|inside| **inside).count()).collect::>(); + let groups = depths.iter().enumerate().filter(|(_, depth)| **depth % 2 == 0).map(|(outer, depth)| { + let mut indices = vec![outer]; + indices.extend((0..loops.len()).filter(|inner| depths[*inner] == *depth + 1 && contained[*inner][outer])); + indices + }).collect::>(); + let entities = groups.iter().map(|indices| { + let profiles = indices.iter().map(|index| loops[*index].clone()).collect::>(); + crate::scene::model::presspull_model::region_from_loops(&profiles, plane) + }).collect::>>(); + let Some(entities) = entities else { + self.discard_last_undo_entry(i); + self.command_line.push_error("HATCHEDIT: boundary cannot form a region."); + return Task::none(); + }; + (entities, groups) + } else { + let rings = crate::scene::hatch_boundary_rings(&source); + if rings.len() != source.paths.len() { + self.discard_last_undo_entry(i); + self.command_line.push_error( + "HATCHEDIT: boundary cannot form a polyline.", + ); + return Task::none(); + } + let entities = crate::scene::boundary_entities(&rings, plane); + if entities.len() != source.paths.len() { + self.discard_last_undo_entry(i); + self.command_line.push_error( + "HATCHEDIT: boundary cannot form a polyline.", + ); + return Task::none(); + } + let groups = (0..entities.len()).map(|index| vec![index]).collect(); + (entities, groups) + }; + let mut path_handles = vec![None; source.paths.len()]; + for (entity, paths) in entities.into_iter().zip(path_groups) { if let Some(boundary) = self.commit_entity_handle(entity) { - handles.push(boundary); + for path in paths { if let Some(slot) = path_handles.get_mut(path) { *slot = Some(boundary); } } } } - if let Some(acadrust::EntityType::Hatch(hatch)) = + if associate { if let Some(acadrust::EntityType::Hatch(hatch)) = self.tabs[i].scene.document.get_entity_mut(handle) { - for (path, boundary) in - hatch.paths.iter_mut().zip(handles.iter().copied()) - { - path.boundary_handles = vec![boundary]; - path.flags.set_external(true); + for (path, boundary) in hatch.paths.iter_mut().zip(path_handles.iter().copied()) { + if let Some(boundary) = boundary { + path.boundary_handles = vec![boundary]; + path.flags.set_external(true); + } } - hatch.is_associative = !handles.is_empty(); - } + hatch.is_associative = path_handles.iter().all(Option::is_some); + } } self.tabs[i].scene.bump_entities(&[( handle, crate::scene::ChangeKind::Modified, @@ -4652,7 +5452,9 @@ impl OpenCADStudio { } } HatchEditOperation::DrawOrderFront - | HatchEditOperation::DrawOrderBack => unreachable!(), + | HatchEditOperation::DrawOrderBack + | HatchEditOperation::DrawOrderBoundary {..} + | HatchEditOperation::BeginAssociate => unreachable!(), } self.tabs[i].dirty = true; self.command_line @@ -4751,6 +5553,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); @@ -5031,6 +5839,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 @@ -5973,6 +6783,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..6e0c93301 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" => { @@ -775,7 +789,7 @@ impl OpenCADStudio { selected, inherited, plane, - ); + ).with_origin(self.tabs[i].scene.document.hatch_origin()); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); self.refresh_area_preview(i); @@ -808,7 +822,7 @@ impl OpenCADStudio { scale, angle, annotative, - ); + ).with_appearance(entity,self.tabs[i].scene.document.header.current_entity_color,self.tabs[i].scene.document.current_entity_transparency()).with_origin(self.tabs[i].scene.document.hatch_origin()); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { @@ -816,7 +830,7 @@ impl OpenCADStudio { .push_error(crate::t!("HATCHEDIT: selected entity is not a hatch.").as_ref()); } } else { - let cmd = HatcheditCommand::new(); + let cmd = HatcheditCommand::new().with_appearance(None,self.tabs[i].scene.document.header.current_entity_color,self.tabs[i].scene.document.current_entity_transparency()).with_origin(self.tabs[i].scene.document.hatch_origin()); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } @@ -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 6921cac64..8d31582af 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)); } @@ -1373,6 +1387,7 @@ impl OpenCADStudio { crate::entities::names::dxf_name(e).to_string(), c.layer.clone(), c.color, + c.transparency, c.linetype.clone(), c.linetype_scale, c.line_weight, @@ -1384,7 +1399,7 @@ impl OpenCADStudio { }, ) }); - let Some((verb, kind, layer, color, linetype, lt_scale, lw, template_dimstyle)) = info + let Some((verb, kind, layer, color, transparency, linetype, lt_scale, lw, template_dimstyle)) = info else { self.command_line .push_error(crate::t!("ADDSELECTED: selected object not found.").as_ref()); @@ -1408,6 +1423,7 @@ impl OpenCADStudio { layer_name: self.tabs[i].scene.document.header.current_layer_name.clone(), layer_handle: self.tabs[i].scene.document.header.current_layer_handle, color: self.tabs[i].scene.document.header.current_entity_color, + transparency: self.tabs[i].scene.document.current_entity_transparency(), linetype_name: self.tabs[i].scene.document.header.current_linetype_name.clone(), linetype_handle: self.tabs[i].scene.document.header.current_linetype_handle, line_weight: self.tabs[i].scene.document.header.current_line_weight, @@ -1422,6 +1438,11 @@ impl OpenCADStudio { ribbon_lineweight: self.ribbon.active_lineweight, }; self.add_selected_restore = Some(restore); + if !self.tabs[i].scene.document.set_current_entity_transparency(transparency) { + self.add_selected_restore = None; + self.command_line.push_error("ADDSELECTED: template transparency cannot be adopted."); + return Task::none(); + } // Adopt the template's general properties as the current defaults. The // entity-creation path stamps new objects from the tab's active layer @@ -1504,6 +1525,9 @@ impl OpenCADStudio { h.current_dimstyle_name = r.dimstyle_name; h.current_dimstyle_handle = r.dimstyle_handle; } + if !self.tabs[i].scene.document.set_current_entity_transparency(r.transparency) { + self.command_line.push_error("ADDSELECTED: current transparency could not be restored."); + } self.tabs[i].active_layer = r.tab_active_layer; self.tabs[i].layers.current_layer = r.tab_layers_current; self.ribbon.active_layer = r.ribbon_layer; diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index e3650b5d1..c847a1d03 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", @@ -368,6 +369,7 @@ inventory::submit!(crate::command::CommandRegistration { "COLOR", "COLOUR", "CECOLOR", + "CETRANSPARENCY", "DDCOLOR", "BYLAYER", // Synchronise block attributes. @@ -598,6 +600,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..65307ed43 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -3,6 +3,10 @@ use super::*; impl OpenCADStudio { pub(super) fn dispatch_styleprops(&mut self, cmd: &str, i: usize) -> Option> { match cmd { + "CETRANSPARENCY" => return self.dispatch_styleprops("SETVAR CETRANSPARENCY", i), + cmd if cmd.starts_with("CETRANSPARENCY ") => { + return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i); + } "FRAMES0" => return self.dispatch_styleprops("SETVAR FRAME 0", i), "FRAMES1" => return self.dispatch_styleprops("SETVAR FRAME 1", i), "FRAMES2" => return self.dispatch_styleprops("SETVAR FRAME 2", i), @@ -645,98 +649,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). @@ -941,9 +1068,34 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - crate::t!("SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), + crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), ); } else { + if name == "CETRANSPARENCY" { + let current = self.tabs[i].scene.document.current_entity_transparency(); + if let Some(value) = &value { + match crate::scene::creation_style::parse_current_transparency(value) { + Some(transparency) => { + if current != transparency { + self.push_undo_snapshot(i, &name); + if !self.tabs[i].scene.document.set_current_entity_transparency(transparency) { + self.discard_last_undo_entry(i); + self.command_line.push_error("CETRANSPARENCY: drawing variable dictionary is invalid."); + return Some(self.finish_dispatch(cmd)); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + } + self.command_line.push_output(&format!("CETRANSPARENCY = {}", crate::scene::creation_style::current_transparency_label(transparency))); + } + None => self.command_line.push_error("CETRANSPARENCY: expected ByLayer (-1), ByBlock (-2), or an integer from 0 to 90."), + } + } else { + self.command_line.push_output(&format!("Enter new value for CETRANSPARENCY <{}>:", crate::scene::creation_style::current_transparency_label(current))); + self.pending_setvar = Some(name.clone()); + } + return Some(self.finish_dispatch(cmd)); + } if matches!(name.as_str(), "SHOWHIST" | "SOLIDHIST") { let current = if name == "SHOWHIST" { self.tabs[i].scene.document.header.show_solid_history.clamp(0, 2) @@ -2983,6 +3135,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 +3362,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..b7444abc9 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; @@ -307,6 +305,7 @@ struct AddSelectedRestore { layer_name: String, layer_handle: acadrust::types::Handle, color: AcadColor, + transparency: acadrust::types::Transparency, linetype_name: String, linetype_handle: acadrust::types::Handle, line_weight: i16, @@ -511,6 +510,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 +547,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 +1048,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 +1734,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 +2089,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 +2517,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 +2816,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 +3528,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 +3546,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 +3739,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..7879843ae 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -306,7 +306,7 @@ impl OpenCADStudio { label: t!("Transparency").into_owned(), field: "transparency", value: PropValue::EditChoice { - value: "ByLayer".to_string(), + value: crate::scene::creation_style::current_transparency_label(self.tabs[i].scene.document.current_entity_transparency()), options: vec!["ByLayer".to_string(), "ByBlock".to_string()], }, }, @@ -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..acb34b13d 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; @@ -1997,9 +2000,6 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if let Some(acadrust::EntityType::Hatch(dxf)) = self.tabs[i].scene.document.get_entity_mut(handle) { - let old_origin = dxf.pattern.lines.first().map(|line| { - (line.base_point.x, line.base_point.y) - }); let mut pattern = hatch_patterns::build_dxf_pattern(entry); // Stored pattern lines are final world-space // geometry. Preserve the selected hatch's scale, @@ -2013,17 +2013,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { &mut pattern, dxf.pattern_angle, ); - if let (Some((old_x, old_y)), Some(new_origin)) = - (old_origin, pattern.lines.first()) - { - let dx = old_x - new_origin.base_point.x; - let dy = old_y - new_origin.base_point.y; - crate::entities::hatch::translate_pattern_geometry( - &mut pattern, - dx, - dy, - ); - } + let origin = dxf.pattern_origin(); + crate::entities::hatch::translate_pattern_geometry(&mut pattern, origin.x, origin.y); dxf.pattern = pattern; dxf.is_solid = matches!( entry.gpu, @@ -2808,7 +2799,28 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } else { match field { "transparency" => { - self.tabs[i].dirty = true; + let Some(transparency) = crate::scene::creation_style::parse_current_transparency(&value) else { + self.command_line.push_error("Transparency: expected ByLayer, ByBlock, or an integer from 0 to 90."); + self.refresh_properties(); + return Task::none(); + }; + if self.tabs[i].scene.document.current_entity_transparency() + != transparency + { + self.push_undo_snapshot(i, "CETRANSPARENCY"); + if self.tabs[i] + .scene + .document + .set_current_entity_transparency(transparency) + { + self.tabs[i].dirty = true; + } else { + self.discard_last_undo_entry(i); + self.command_line.push_error( + "CETRANSPARENCY: drawing variable dictionary is invalid.", + ); + } + } self.refresh_properties(); } "material" => { 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..2867268c0 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() @@ -3847,13 +3870,22 @@ impl OpenCADStudio { }; use crate::command::CadCommand; use crate::modules::draw::draw::hatchedit::HatcheditCommand; - let cmd: Box = Box::new(HatcheditCommand::with_handle( - handle, - model.name.clone(), - scale, - angle, - annotative, - )); + let current_color = + self.tabs[i].scene.document.header.current_entity_color; + let current_transparency = + self.tabs[i].scene.document.current_entity_transparency(); + let current_origin = self.tabs[i].scene.document.hatch_origin(); + let cmd: Box = Box::new( + HatcheditCommand::with_handle( + handle, + model.name.clone(), + scale, + angle, + annotative, + ) + .with_appearance(entity, current_color, current_transparency) + .with_origin(current_origin), + ); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(cmd); } else { @@ -3887,6 +3919,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 +3996,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 3f026350d..d9860ad85 100644 --- a/src/command.rs +++ b/src/command.rs @@ -13,13 +13,22 @@ use glam::DVec3; #[derive(Clone, Debug)] pub enum HatchEditOperation { + Appearance { + color: Option, + layer: Option, + transparency: Option, + }, Update { origin: Option<(f64, f64)>, + store_origin: bool, disassociate: bool, style: Option, annotative: Option, }, - RecreateBoundary, + RecreateBoundary { associate: bool, region: bool }, + BeginAssociate, + AssociatePaths(Vec), + DrawOrderBoundary { above: bool }, Separate, AddBoundaries(Vec), RemoveBoundaries(Vec), @@ -1408,6 +1417,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 +1538,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, @@ -1947,7 +2012,21 @@ pub struct CurveMarker { } +/// 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 { @@ -2014,6 +2093,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; @@ -2294,6 +2379,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> { @@ -2478,6 +2566,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::*; @@ -2516,4 +2638,3 @@ mod tests { assert!(names.contains(&"M2P")); } } - diff --git a/src/entities/hatch.rs b/src/entities/hatch.rs index 05036ee30..0acf8ebde 100644 --- a/src/entities/hatch.rs +++ b/src/entities/hatch.rs @@ -182,18 +182,14 @@ fn boundary_centroid(h: &Hatch) -> Option<(f64, f64)> { (n > 0.0).then(|| (sx / n, sy / n)) } -/// Scale the stored, final hatch-pattern geometry around the pattern origin. +/// Scale a catalog pattern about its intrinsic coordinate origin. /// Pattern lines loaded from DXF/DWG are rendered prebaked, so their metadata /// scale is not applied again by the renderer. pub(crate) fn scale_pattern_geometry( pattern: &mut acadrust::entities::HatchPattern, factor: f64, ) { - let (origin_x, origin_y) = pattern - .lines - .first() - .map(|line| (line.base_point.x, line.base_point.y)) - .unwrap_or((0.0, 0.0)); + let (origin_x, origin_y) = (0.0, 0.0); for line in pattern.lines.iter_mut() { line.base_point.x = origin_x + (line.base_point.x - origin_x) * factor; line.base_point.y = origin_y + (line.base_point.y - origin_y) * factor; @@ -205,18 +201,14 @@ pub(crate) fn scale_pattern_geometry( } } -/// Rotate the stored, final hatch-pattern geometry around the pattern origin. +/// Rotate a catalog pattern about its intrinsic coordinate origin. /// This keeps the line angle, base point and world-space offset in sync. pub(crate) fn rotate_pattern_geometry( pattern: &mut acadrust::entities::HatchPattern, angle: f64, ) { let (sin, cos) = angle.sin_cos(); - let (origin_x, origin_y) = pattern - .lines - .first() - .map(|line| (line.base_point.x, line.base_point.y)) - .unwrap_or((0.0, 0.0)); + let (origin_x, origin_y) = (0.0, 0.0); for line in pattern.lines.iter_mut() { line.angle += angle; let (x, y) = ( @@ -613,7 +605,7 @@ fn apply_geom_prop(h: &mut Hatch, field: &str, value: &str) { _ => h.pattern_type, }; if requested != h.pattern_type { - let old_origin = h.pattern.lines.first().map(|line| line.base_point); + let old_origin = h.pattern_origin(); h.pattern_type = requested; h.is_solid = false; match requested { @@ -629,15 +621,7 @@ fn apply_geom_prop(h: &mut Hatch, field: &str, value: &str) { crate::scene::model::hatch_patterns::build_dxf_pattern(entry); scale_pattern_geometry(&mut pattern, h.pattern_scale); rotate_pattern_geometry(&mut pattern, h.pattern_angle); - if let (Some(old), Some(new)) = - (old_origin, pattern.lines.first().map(|line| line.base_point)) - { - translate_pattern_geometry( - &mut pattern, - old.x - new.x, - old.y - new.y, - ); - } + translate_pattern_geometry(&mut pattern, old_origin.x, old_origin.y); h.pattern = pattern; } } @@ -695,7 +679,7 @@ fn apply_geom_prop(h: &mut Hatch, field: &str, value: &str) { "pattern_angle" => { let angle = v.to_radians(); let delta = angle - h.pattern_angle; - rotate_pattern_geometry(&mut h.pattern, delta); + h.rotate_pattern_about_origin(delta); h.pattern_angle = angle; } // The stored pattern lines are the FINAL rendered geometry (offsets, @@ -706,7 +690,7 @@ fn apply_geom_prop(h: &mut Hatch, field: &str, value: &str) { let old = h.pattern_scale; if old > 1e-12 { let k = v / old; - scale_pattern_geometry(&mut h.pattern, k); + h.scale_pattern_about_origin(k); } h.pattern_scale = v; } @@ -724,19 +708,17 @@ fn apply_geom_prop(h: &mut Hatch, field: &str, value: &str) { } // Origin rows are relative offsets and return to zero after commit. "origin_x" => { - for line in h.pattern.lines.iter_mut() { - line.base_point.x += v; - } + let origin = h.pattern_origin(); + h.set_pattern_origin(acadrust::types::Vector2::new(origin.x + v, origin.y)); } "origin_y" => { - for line in h.pattern.lines.iter_mut() { - line.base_point.y += v; - } + let origin = h.pattern_origin(); + h.set_pattern_origin(acadrust::types::Vector2::new(origin.x, origin.y + v)); } "iso_pen_width" if v > 0.0 => { let old = h.pattern_scale; if old > 1e-12 { - scale_pattern_geometry(&mut h.pattern, v / old); + h.scale_pattern_about_origin(v / old); } h.pattern_scale = v; } @@ -876,10 +858,8 @@ impl Grippable for Hatch { GripApply::Absolute(point) => (point.x - gx, point.y - gy), GripApply::Translate(delta) => (delta.x, delta.y), }; - for line in self.pattern.lines.iter_mut() { - line.base_point.x += dx; - line.base_point.y += dy; - } + let origin = self.pattern_origin(); + self.set_pattern_origin(acadrust::types::Vector2::new(origin.x + dx, origin.y + dy)); return; } id += 1; @@ -1005,11 +985,11 @@ impl Grippable for Hatch { fn apply_grip_menu(&mut self, grip_id: usize, action: crate::scene::model::object::GripMenuAction) { use crate::scene::model::object::GripMenuAction as A; if grip_id == 0 && matches!(action, A::OriginPoint) { - if let (Some((gx, gy)), Some((ox, oy))) = ( + if let (Some((gx, gy)), Some((_ox, _oy))) = ( boundary_centroid(self), self.pattern.lines.first().map(|line| (line.base_point.x, line.base_point.y)), ) { - translate_pattern_geometry(&mut self.pattern, gx - ox, gy - oy); + self.set_pattern_origin(acadrust::types::Vector2::new(gx, gy)); } } } @@ -1038,14 +1018,14 @@ impl Grippable for Hatch { A::HatchAngle => { let angle = value.to_radians(); let delta = angle - self.pattern_angle; - rotate_pattern_geometry(&mut self.pattern, delta); + self.rotate_pattern_about_origin(delta); self.pattern_angle = angle; } A::HatchScale => { if value > 0.0 { if self.pattern_scale > 1e-12 { let factor = value / self.pattern_scale; - scale_pattern_geometry(&mut self.pattern, factor); + self.scale_pattern_about_origin(factor); } self.pattern_scale = value; } 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/pdf_export.rs b/src/io/pdf_export.rs index 10b72da48..5dc8161e1 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -927,6 +927,7 @@ fn emit_wire_fills( } boundary.push(boundary[0]); let hatch = HatchModel { + pattern_origin: None, render_instance: wire.render_instance.clone(), world_origin: [0.0, 0.0], boundary: std::sync::Arc::new(boundary), 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/hatch.rs b/src/modules/draw/draw/hatch.rs index 7a51f986e..3ce2a4c1a 100644 --- a/src/modules/draw/draw/hatch.rs +++ b/src/modules/draw/draw/hatch.rs @@ -196,6 +196,7 @@ pub struct HatchCommand { pattern_override: Option<(String, HatchPattern)>, angle_override: Option, scale_override: Option, + default_origin: [f64; 2], associative: bool, separate_hatches: bool, island_style: acadrust::entities::HatchStyleType, @@ -244,6 +245,7 @@ impl HatchCommand { pattern_override: None, angle_override: None, scale_override: None, + default_origin: [0.0, 0.0], associative: true, separate_hatches: false, island_style: inherited @@ -257,6 +259,8 @@ impl HatchCommand { command } + pub fn with_origin(mut self, origin: [f64; 2]) -> Self { self.default_origin = origin; self } + fn set_object_selection(&mut self, handles: Vec) { let mut segments = Vec::new(); for handle in &handles { @@ -383,6 +387,7 @@ impl HatchCommand { } } return HatchModel { + pattern_origin: source.pattern_origin, render_instance: None, boundary: std::sync::Arc::new(rel), pattern, @@ -425,11 +430,23 @@ impl HatchCommand { dashes: vec![], }]) }); - let (name, pattern) = self + let (name, mut pattern) = self .pattern_override .clone() .unwrap_or_else(|| (pat_name.to_string(), default_pattern)); + let angle = self.angle_override.unwrap_or(0.0); + let scale = self.scale_override.unwrap_or(1.0).max(1.0e-6); + if let (HatchPattern::Pattern(families), Some(anchor)) = (&mut pattern, local_boundary.first()) { + let (sin, cos) = (angle as f64).sin_cos(); + let dx = self.default_origin[0] - anchor[0] as f64; + let dy = self.default_origin[1] - anchor[1] as f64; + for family in families { + family.x0 += ((dx * cos + dy * sin) / scale as f64) as f32; + family.y0 += ((-dx * sin + dy * cos) / scale as f64) as f32; + } + } HatchModel { + pattern_origin: Some(self.default_origin), render_instance: None, boundary: std::sync::Arc::new(rel), pattern, @@ -954,6 +971,7 @@ impl GradientCommand { 1.0e-6, ); HatchModel { + pattern_origin: None, render_instance: None, boundary: std::sync::Arc::new(rel), pattern: HatchPattern::Gradient { diff --git a/src/modules/draw/draw/hatchedit.rs b/src/modules/draw/draw/hatchedit.rs index d88ed407d..917b4e17e 100644 --- a/src/modules/draw/draw/hatchedit.rs +++ b/src/modules/draw/draw/hatchedit.rs @@ -28,9 +28,23 @@ pub struct HatcheditCommand { step: HatcheditStep, origin: Option<(f64, f64)>, disassociate: bool, + store_origin: bool, + current_origin: [f64; 2], + origin_plane: cadkernel::space::Plane, + origin_bounds: Option<([f64;2],[f64;2])>, + origin_bounds_unavailable: bool, style: Option, annotative: Option, annotative_current: bool, + style_current: acadrust::entities::HatchStyleType, + input: Option<&'static str>, + source_appearance: Option<(acadrust::types::Color,String,acadrust::types::Transparency)>, + current_color: acadrust::types::Color, + current_transparency: acadrust::types::Transparency, + boundary_region: bool, + association_sources: Option<(crate::command::WorkingPlane,rustc_hash::FxHashMap)>, + association_paths: Vec, + association_missed: bool, } impl HatcheditCommand { @@ -39,9 +53,23 @@ impl HatcheditCommand { step: HatcheditStep::PickHatch, origin: None, disassociate: false, + store_origin: false, + origin_bounds: None, + origin_bounds_unavailable: false, + current_origin: [0.0, 0.0], + origin_plane: cadkernel::space::Plane::from_axes([0.0;3],[1.0,0.0,0.0],[0.0,1.0,0.0]), style: None, annotative: None, annotative_current: false, + style_current: acadrust::entities::HatchStyleType::Normal, + input: None, + source_appearance: None, + current_color: acadrust::types::Color::ByLayer, + current_transparency: acadrust::types::Transparency::ByLayer, + boundary_region: false, + association_sources: None, + association_paths: Vec::new(), + association_missed: false, } } @@ -61,9 +89,23 @@ impl HatcheditCommand { }, origin: None, disassociate: false, + store_origin: false, + origin_bounds: None, + origin_bounds_unavailable: false, + current_origin: [0.0, 0.0], + origin_plane: cadkernel::space::Plane::from_axes([0.0;3],[1.0,0.0,0.0],[0.0,1.0,0.0]), style: None, annotative: None, annotative_current: annotative, + style_current: acadrust::entities::HatchStyleType::Normal, + input: None, + source_appearance: None, + current_color: acadrust::types::Color::ByLayer, + current_transparency: acadrust::types::Transparency::ByLayer, + boundary_region: false, + association_sources: None, + association_paths: Vec::new(), + association_missed: false, } } @@ -86,9 +128,57 @@ impl HatcheditCommand { }) } + pub fn for_association(handle:Handle,name:String,scale:f32,angle:f32,plane:crate::command::WorkingPlane,sources:rustc_hash::FxHashMap)->Self { + let mut command=Self::with_handle(handle,name,scale,angle,false); + command.input=Some("associate-point");command.association_sources=Some((plane,sources));command + } + fn add_association_rings(&mut self,rings:Vec>,sources:&rustc_hash::FxHashMap) { + let exterior=cadkernel::geom2d::ring_nesting_depths(&rings).into_iter().map(|depth|depth==0).collect::>(); + let paths=crate::scene::exact_hatch_paths(&rings,&exterior,sources,1e-6); + self.association_missed=paths.is_empty()||paths.len()!=rings.len()||paths.iter().any(|path|path.boundary_handles.is_empty()); + if !self.association_missed { + for path in paths {if !self.association_paths.contains(&path){self.association_paths.push(path);}} + } + } + pub fn with_appearance(mut self,entity:Option<&acadrust::EntityType>,current_color:acadrust::types::Color,current_transparency:acadrust::types::Transparency)->Self { + if let Some(acadrust::EntityType::Hatch(hatch)) = entity { + self.style_current = hatch.style; + self.origin_plane = crate::entities::curve::ocs_plane(hatch.normal, hatch.elevation); + self.origin_bounds = hatch.paths.iter().flat_map(|path| &path.edges) + .map(crate::entities::hatch::edge_curve).collect::>>() + .and_then(|curves| { + // Full ellipses use the four axis endpoints as origin anchors, + // not their geometric extrema. Keep the general bounds contract + // unchanged and aggregate these command-specific anchor segments. + let mut anchors = Vec::new(); + for curve in curves { + match curve { + cadkernel::geom2d::Curve::Ellipse(arc) => { + if (arc.end_parameter - arc.start_parameter).abs() < std::f64::consts::TAU { return None; } + for angle in [0.0, std::f64::consts::FRAC_PI_2] { + anchors.push(cadkernel::geom2d::Curve::Line(cadkernel::geom2d::Line { + start: arc.ellipse.point_at(angle), + end: arc.ellipse.point_at(angle + std::f64::consts::PI), + })); + } + } + cadkernel::geom2d::Curve::Nurbs(_) => return None, + other => anchors.push(other), + } + } + cadkernel::geom2d::analytic_curve_bounds(&anchors) + }); + } + self.source_appearance=entity.map(|e|{let c=e.common();(c.color,c.layer.clone(),c.transparency)}); + self.current_color=current_color;self.current_transparency=current_transparency;self + } + + pub fn with_origin(mut self, origin: [f64; 2]) -> Self { self.current_origin = origin; self } + fn update_operation(&self) -> HatchEditOperation { HatchEditOperation::Update { origin: self.origin, + store_origin: self.store_origin, disassociate: self.disassociate, style: self.style, annotative: self.annotative, @@ -102,6 +192,58 @@ impl CadCommand for HatcheditCommand { } fn prompt(&self) -> String { + if let Some(input)=self.input { + if input == "style" { + let current = match self.style_current { + acadrust::entities::HatchStyleType::Normal => "Normal", + acadrust::entities::HatchStyleType::Outer => "Outer", + acadrust::entities::HatchStyleType::Ignore => "Ignore", + }; + return format!("Enter hatching style [Ignore/Outer/Normal] <{current}>:"); + } + if let Some((color,layer,transparency))=&self.source_appearance { + match input { + "color"=>return format!("New color [Truecolor/. (for use current)] <{color:?}>:"), + "layer"=>return format!("Specify layer or [. (for use current)] <{layer}>:"), + "transparency"=>return format!("Specify transparency (0-90) or ByLayer/ByBlock <{}>:",match transparency { + acadrust::types::Transparency::ByLayer=>"ByLayer".into(), + acadrust::types::Transparency::ByBlock=>"ByBlock".into(), + value=>format!("{:.0}",value.as_percent()*100.0), + }), + _=>{}, + } + } + if let HatcheditStep::EditOptions{name,scale,angle,..}=&self.step { + match input { + "pattern"=>return format!("Enter a pattern name or [Solid] <{name}>:"), + "scale"=>return format!("Specify a scale for the pattern <{scale:.4}>:"), + "angle"=>return format!("Specify an angle for the pattern <{angle:.4}>:"), + _=>{}, + } + } + return match input { + "annotative"=>if self.annotative_current {"Make hatch annotative [Yes/No] :"} else {"Make hatch annotative [Yes/No] :"}, + "origin" if self.origin_bounds_unavailable=>"Boundary extents unavailable for this geometry. [Use current origin/Set new origin/Default to boundary extents] :", + "origin"=>"[Use current origin/Set new origin/Default to boundary extents] :", + "origin-extents"=>"[bottom Left/bottom Right/top rIght/top lEft/Center] :", + "origin-point"=>"Select point:", + "origin-store"=>"Store as default origin? [Yes/No] :", + "pattern"=>"Enter a pattern name or [Solid]:", + "scale"=>"Specify a scale for the pattern:", + "angle"=>"Specify an angle for the pattern:", + "color"=>"New color [Truecolor] :", + "truecolor"=>"Specify RGB color (red,green,blue):", + "layer"=>"Specify layer or [. (for use current)]:", + "transparency"=>"Specify transparency value (0-90) or ByLayer/ByBlock:", + "draworder"=>"Enter draw order [do Not change/send to Back/bring to Front/send beHind boundary/bring in front of bounDary] :", + "boundary-type"=>"Enter type of boundary object [Region/Polyline] :", + "boundary-associate"=>"Reassociate hatch with new boundary? [Yes/No] :", + "associate-select"=>"Select boundary objects:", + "associate-point" if self.association_missed=>"No closed boundary found. Specify internal point or [Select objects]:", + "associate-point"=>"Specify internal point or [Select objects]:", + _=>"Specify value:", + }.into(); + } match &self.step { HatcheditStep::PickHatch => t!("HATCHEDIT Select hatch:").into_owned(), HatcheditStep::EditOptions { @@ -110,7 +252,7 @@ impl CadCommand for HatcheditCommand { let scale = format!("{scale:.4}"); let angle = format!("{angle:.1}"); t!( - "HATCHEDIT Pattern:%{name} Scale:%{scale} Angle:%{angle} [P pattern / S scale / A angle / O x,y / D disassociate / Y style / N annotative / R recreate / E separate / + handles / - handles | Enter]:", + "HATCHEDIT Pattern:%{name} Scale:%{scale} Angle:%{angle} [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] :", name = name, scale = scale, angle = angle @@ -123,6 +265,15 @@ impl CadCommand for HatcheditCommand { fn needs_entity_pick(&self) -> bool { matches!(self.step, HatcheditStep::PickHatch) } + fn is_selection_gathering(&self)->bool {self.input==Some("associate-select")} + fn on_selection_complete(&mut self,handles:Vec)->CmdResult { + if let Some((_,sources))=&self.association_sources { + let sources=sources.iter().filter(|(handle,_)|handles.contains(handle)).map(|(handle,source)|(*handle,source.clone())).collect(); + let rings=crate::scene::boundary_faces(&sources,1e-6); + self.add_association_rings(rings,&sources); + } + self.input=Some("associate-point");CmdResult::NeedPoint + } fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { if handle.is_null() { @@ -140,25 +291,159 @@ impl CadCommand for HatcheditCommand { } fn wants_text_input(&self) -> bool { - matches!(self.step, HatcheditStep::EditOptions { .. }) + matches!(self.step, HatcheditStep::EditOptions { .. })&&!self.is_selection_gathering() } fn options(&self) -> Vec { + if self.input==Some("associate-point") {return vec![crate::command::CmdOption::new("Select objects","S")];} + if let Some(input) = self.input { + use crate::command::CmdOption; + return match input { + "origin" => vec![CmdOption::new("Use current origin", "U"), CmdOption::new("Set new origin", "S"), CmdOption::new("Default to boundary extents", "D")], + "origin-extents" => vec![CmdOption::new("Bottom left", "L"), CmdOption::new("Bottom right", "R"), CmdOption::new("Top right", "I"), CmdOption::new("Top left", "E"), CmdOption::new("Center", "C")], + "origin-store" => vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")], + "style" => vec![CmdOption::new("Ignore", "I"), CmdOption::new("Outer", "O"), CmdOption::new("Normal", "N")], + "draworder" => vec![CmdOption::new("Do not change", "N"), CmdOption::new("Send to back", "B"), CmdOption::new("Bring to front", "F"), CmdOption::new("Behind boundary", "H"), CmdOption::new("In front of boundary", "D")], + "annotative" | "boundary-associate" => vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")], + "boundary-type" => vec![CmdOption::new("Region", "R"), CmdOption::new("Polyline", "P")], + "color" => vec![CmdOption::new("Truecolor", "T")], + "pattern" => vec![CmdOption::new("Solid", "SOLID")], + _ => Vec::new(), + }; + } if !matches!(self.step, HatcheditStep::EditOptions { .. }) { return Vec::new(); } vec![ - crate::command::CmdOption::new("Disassociate", "D"), - crate::command::CmdOption::new("Annotative", "N"), - crate::command::CmdOption::new("Recreate boundary", "R"), - crate::command::CmdOption::new("Separate hatches", "E"), - crate::command::CmdOption::new("Draw front", "F"), - crate::command::CmdOption::new("Draw back", "B"), - crate::command::CmdOption::enter("Apply"), + crate::command::CmdOption::new("Style", "S"), + crate::command::CmdOption::new("Origin", "O"), + crate::command::CmdOption::new("Properties", "P"), + crate::command::CmdOption::new("Color", "CO"), + crate::command::CmdOption::new("Layer", "LA"), + crate::command::CmdOption::new("Transparency", "T"), + crate::command::CmdOption::new("Draw order", "DR"), + crate::command::CmdOption::new("Associate", "AS"), + crate::command::CmdOption::new("Disassociate", "DI"), + crate::command::CmdOption::new("Annotative", "AN"), + crate::command::CmdOption::new("Recreate boundary", "B"), + crate::command::CmdOption::new("Separate hatches", "H"), + crate::command::CmdOption::enter("Properties"), ] } fn on_text_input(&mut self, text: &str) -> Option { + let keyword=text.trim().to_ascii_uppercase(); + if let Some(input)=self.input { + use acadrust::types::{Color,Transparency}; + let appearance=|color,layer,transparency|HatchEditOperation::Appearance{color,layer,transparency}; + match input { + "origin" => match keyword.as_str() { + "U" | "USE" => { self.origin = Some((self.current_origin[0], self.current_origin[1])); return self.apply_result(self.update_operation()); } + "S" | "SET" => { self.input = Some("origin-point"); } + "D" | "DEFAULT" => { + self.origin_bounds_unavailable = self.origin_bounds.is_none(); + if !self.origin_bounds_unavailable { self.input = Some("origin-extents"); } + } + _ => {}, + }, + "origin-extents" => { + if let Some((min,max)) = self.origin_bounds { + let origin = match keyword.as_str() { + "L" | "LEFT" => min, + "R" | "RIGHT" => [max[0],min[1]], + "I" | "TOP RIGHT" => max, + "E" | "TOP LEFT" => [min[0],max[1]], + "C" | "CENTER" => [min[0]*0.5+max[0]*0.5,min[1]*0.5+max[1]*0.5], + _ => return Some(CmdResult::NeedPoint), + }; + self.origin = Some((origin[0],origin[1])); + self.input = Some("origin-store"); + } + } + "origin-point" => { + let values: Option> = text.split(',').map(|s| s.trim().parse().ok()).collect(); + if let Some(values) = values.filter(|v| (2..=3).contains(&v.len()) && v.iter().all(|n| n.is_finite())) { + return Some(self.on_point(DVec3::new(values[0], values[1], values.get(2).copied().unwrap_or(0.0)))); + } + } + "origin-store" => { + self.store_origin = match keyword.as_str() { "Y" | "YES" => true, "N" | "NO" => false, _ => return Some(CmdResult::NeedPoint) }; + return self.apply_result(self.update_operation()); + } + "style" => { + self.style = match keyword.as_str() { + "I" | "IGNORE" => Some(acadrust::entities::HatchStyleType::Ignore), + "O" | "OUTER" => Some(acadrust::entities::HatchStyleType::Outer), + "N" | "NORMAL" => Some(acadrust::entities::HatchStyleType::Normal), + _ => return Some(CmdResult::NeedPoint), + }; + return self.apply_result(self.update_operation()); + } + "annotative" => { + let value = match keyword.as_str() { "Y" | "YES" => true, "N" | "NO" => false, _ => return Some(CmdResult::NeedPoint) }; + self.annotative = Some(value); + return self.apply_result(self.update_operation()); + } + "associate-point"=>{ + if matches!(keyword.as_str(),"S"|"SELECT"|"SELECT OBJECTS") {self.input=Some("associate-select");} + return Some(CmdResult::NeedPoint); + }, + "color"=>{ + if matches!(keyword.as_str(),"T"|"TRUECOLOR") {self.input=Some("truecolor");return Some(CmdResult::NeedPoint);} + let color=match keyword.as_str(){"."=>Some(self.current_color),"BYLAYER"=>Some(Color::ByLayer),"BYBLOCK"=>Some(Color::ByBlock), + "RED"=>Some(Color::Index(1)),"YELLOW"=>Some(Color::Index(2)),"GREEN"=>Some(Color::Index(3)), + "CYAN"=>Some(Color::Index(4)),"BLUE"=>Some(Color::Index(5)),"MAGENTA"=>Some(Color::Index(6)),"WHITE"=>Some(Color::Index(7)), + n=>n.parse::().ok().filter(|v|(0..=256).contains(v)).map(Color::from_index)}; + return Some(color.and_then(|v|self.apply_result(appearance(Some(v),None,None))).unwrap_or(CmdResult::NeedPoint)); + } + "truecolor"=>{ + let rgb:Option>=keyword.split(',').map(|s|s.trim().parse().ok()).collect(); + if let Some(rgb)=rgb.filter(|rgb|rgb.len()==3) {return self.apply_result(appearance(Some(Color::from_rgb(rgb[0],rgb[1],rgb[2])),None,None));} + } + "layer"=>if !text.trim().is_empty(){return self.apply_result(appearance(None,Some(text.trim().to_owned()),None));}, + "transparency"=>{ + let value=match keyword.as_str(){"."=>Some(self.current_transparency),"BYLAYER"=>Some(Transparency::BY_LAYER),"BYBLOCK"=>Some(Transparency::BY_BLOCK), + n=>n.parse::().ok().filter(|v|*v<=90).map(|v|Transparency::from_percent(v as f64 / 100.0))}; + if let Some(value)=value{return self.apply_result(appearance(None,None,Some(value)));} + } + "draworder"=>return match keyword.as_str(){"F"|"FRONT"=>self.apply_result(HatchEditOperation::DrawOrderFront), + "H"|"BEHIND"=>self.apply_result(HatchEditOperation::DrawOrderBoundary{above:false}), + "D"=>self.apply_result(HatchEditOperation::DrawOrderBoundary{above:true}), + "B"|"BACK"=>self.apply_result(HatchEditOperation::DrawOrderBack),"N"|"NOT"=>Some(CmdResult::Cancel),_=>Some(CmdResult::NeedPoint)}, + "boundary-type"=>match keyword.as_str(){ + "P"|"POLYLINE"=>{self.boundary_region=false;self.input=Some("boundary-associate");}, + "R"|"REGION"=>{self.boundary_region=true;self.input=Some("boundary-associate");}, + _=>{}, + }, + "boundary-associate"=>return match keyword.as_str(){ + "Y"|"YES"=>self.apply_result(HatchEditOperation::RecreateBoundary{associate:true,region:self.boundary_region}), + "N"|"NO"=>self.apply_result(HatchEditOperation::RecreateBoundary{associate:false,region:self.boundary_region}), + _=>Some(CmdResult::NeedPoint), + }, + "pattern"=>{ + if crate::scene::model::hatch_patterns::find(&keyword).is_some() { + if let HatcheditStep::EditOptions{name,..}=&mut self.step{*name=keyword.clone();} + if keyword=="SOLID" {return self.apply_result(self.update_operation());} + self.input=Some("scale"); + } + } + "scale"|"angle"=>if let Ok(value)=keyword.parse::() {if value.is_finite()&&(input=="angle"||value>0.0){ + if let HatcheditStep::EditOptions{scale,angle,..}=&mut self.step{if input=="scale"{*scale=value;}else{*angle=value;}} + if input=="scale"{self.input=Some("angle");}else{return self.apply_result(self.update_operation());} + }}, + _=>{}, + } + return Some(CmdResult::NeedPoint); + } + let next=match keyword.as_str(){"O"|"ORIGIN"=>Some("origin"),"S"|"STYLE"=>Some("style"),"P"|"PROPERTIES"=>Some("pattern"),"CO"|"COLOR"=>Some("color"),"LA"|"LAYER"=>Some("layer"), + "AN"|"ANNOTATIVE"=>Some("annotative"),"T"|"TRANSPARENCY"=>Some("transparency"),"DR"|"DRAW"|"DRAW ORDER"=>Some("draworder"), + "B"|"BOUNDARY"|"R"|"RECREATE"=>Some("boundary-type"),_=>None}; + if let Some(input)=next {self.input=Some(input);return Some(CmdResult::NeedPoint);} + if matches!(keyword.as_str(),"H"|"HATCHES"|"SEPARATE") {return self.apply_result(HatchEditOperation::Separate);} + if matches!(keyword.as_str(),"AS"|"ASSOCIATE"){return self.apply_result(HatchEditOperation::BeginAssociate);} + if matches!(keyword.as_str(),"DI"|"DISASSOCIATE"){ + self.disassociate=true;return self.apply_result(self.update_operation()); + } let (_handle, name, scale, angle) = match &mut self.step { HatcheditStep::EditOptions { handle, @@ -236,7 +521,7 @@ impl CadCommand for HatcheditCommand { return Some(CmdResult::NeedPoint); } if text == "R" || text == "RECREATE" { - return self.apply_result(HatchEditOperation::RecreateBoundary); + self.input=Some("boundary-type");return Some(CmdResult::NeedPoint); } if text == "E" || text == "SEPARATE" { return self.apply_result(HatchEditOperation::Separate); @@ -269,18 +554,295 @@ impl CadCommand for HatcheditCommand { Some(CmdResult::NeedPoint) } - fn on_point(&mut self, _pt: DVec3) -> CmdResult { + fn on_point(&mut self, pt: DVec3) -> CmdResult { + if self.input == Some("origin-point") { + if let Some(point) = self.origin_plane.project(pt.to_array()) { + if point.iter().all(|value| value.is_finite()) { + self.origin = Some((point[0], point[1])); + self.input = Some("origin-store"); + } + } + return CmdResult::NeedPoint; + } + if self.input==Some("associate-point") { + if let Some((plane,sources))=&self.association_sources { + let point=plane.to_local(pt); + let sources=sources.clone(); + if let Some(rings)=crate::scene::model::presspull_model::selected_rings(&sources,[point.x,point.y]) { + self.add_association_rings(rings,&sources); + }else{self.association_missed=true;} + } + } CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { - // Enter without text → apply current settings - self.apply_result(self.update_operation()).unwrap_or(CmdResult::Cancel) + match self.input { + None if matches!(self.step,HatcheditStep::EditOptions{..})=>{ + self.input=Some("pattern");CmdResult::NeedPoint + } + Some("origin")=>{self.origin=Some((self.current_origin[0],self.current_origin[1]));self.apply_result(self.update_operation()).unwrap_or(CmdResult::Cancel)}, + Some("origin-extents")=>{ + if let Some((min,_)) = self.origin_bounds { self.origin=Some((min[0],min[1])); self.input=Some("origin-store"); } + CmdResult::NeedPoint + }, + Some("origin-point")=>CmdResult::NeedPoint, + Some("origin-store")=>self.apply_result(self.update_operation()).unwrap_or(CmdResult::Cancel), + Some("style")=>{self.style=Some(self.style_current);self.apply_result(self.update_operation()).unwrap_or(CmdResult::Cancel)}, + Some("annotative")=>{self.annotative=Some(self.annotative_current);self.apply_result(self.update_operation()).unwrap_or(CmdResult::Cancel)}, + Some("pattern")=>{ + if matches!(&self.step,HatcheditStep::EditOptions{name,..} if name.eq_ignore_ascii_case("SOLID")) { + self.apply_result(self.update_operation()).unwrap_or(CmdResult::Cancel) + }else{self.input=Some("scale");CmdResult::NeedPoint} + } + Some("scale")=>{self.input=Some("angle");CmdResult::NeedPoint} + Some("angle")=>self.apply_result(self.update_operation()).unwrap_or(CmdResult::Cancel), + Some("boundary-type")=>{self.boundary_region=false;self.input=Some("boundary-associate");CmdResult::NeedPoint}, + Some("boundary-associate")=>self.apply_result(HatchEditOperation::RecreateBoundary{associate:false,region:self.boundary_region}).unwrap_or(CmdResult::Cancel), + Some("associate-select")=>{self.input=Some("associate-point");CmdResult::NeedPoint}, + Some("associate-point")=>if self.association_paths.is_empty(){CmdResult::Cancel}else{ + self.apply_result(HatchEditOperation::AssociatePaths(self.association_paths.clone())).unwrap_or(CmdResult::Cancel) + }, + _=>CmdResult::Cancel, + } } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } } +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::hatch::{ + BoundaryEdge, BoundaryPath, EllipticArcEdge, LineEdge, + }; + use acadrust::entities::{Hatch, HatchStyleType}; + use acadrust::types::{Color, Transparency, Vector2}; + use acadrust::EntityType; + + fn hatch_command() -> HatcheditCommand { + let mut hatch = Hatch::new(); + hatch.common.handle = Handle::new(7); + hatch.common.layer = "Boundary".into(); + hatch.common.color = Color::Index(3); + hatch.common.transparency = Transparency::T_20; + hatch.style = HatchStyleType::Outer; + let mut path = BoundaryPath::new(); + for (start, end) in [ + ([1.0, 2.0], [5.0, 2.0]), + ([5.0, 2.0], [5.0, 6.0]), + ([5.0, 6.0], [1.0, 6.0]), + ([1.0, 6.0], [1.0, 2.0]), + ] { + 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 entity = EntityType::Hatch(hatch); + HatcheditCommand::with_handle(Handle::new(7), "ANSI31".into(), 1.0, 0.0, false) + .with_appearance(Some(&entity), Color::Index(6), Transparency::T_40) + .with_origin([9.0, 8.0]) + } + + #[test] + fn properties_stages_validate_and_apply_pattern_values() { + let mut command = hatch_command(); + assert!(matches!(command.on_enter(), CmdResult::NeedPoint)); + assert!(command.prompt().contains("ANSI31")); + assert!(matches!( + command.on_text_input("missing"), + Some(CmdResult::NeedPoint) + )); + assert!(matches!( + command.on_text_input("ANSI31"), + Some(CmdResult::NeedPoint) + )); + assert!(matches!( + command.on_text_input("0"), + Some(CmdResult::NeedPoint) + )); + assert!(matches!( + command.on_text_input("2.5"), + Some(CmdResult::NeedPoint) + )); + let Some(CmdResult::HatcheditApply { + name, + scale, + angle, + operation: HatchEditOperation::Update { .. }, + .. + }) = command.on_text_input("30") + else { + panic!("expected a validated hatch update"); + }; + assert_eq!(name, "ANSI31"); + assert_eq!(scale, 2.5); + assert_eq!(angle, 30.0); + } + + #[test] + fn appearance_stages_keep_exact_color_layer_and_transparency_choices() { + let mut color = hatch_command(); + assert!(matches!( + color.on_text_input("CO"), + Some(CmdResult::NeedPoint) + )); + assert!(matches!( + color.on_text_input("red"), + Some(CmdResult::HatcheditApply { + operation: HatchEditOperation::Appearance { + color: Some(Color::Index(1)), + .. + }, + .. + }) + )); + + let mut layer = hatch_command(); + assert!(matches!( + layer.on_text_input("LA"), + Some(CmdResult::NeedPoint) + )); + assert!(matches!( + layer.on_text_input("CaseSensitiveLayer"), + Some(CmdResult::HatcheditApply { + operation: HatchEditOperation::Appearance { + layer: Some(value), + .. + }, + .. + }) if value == "CaseSensitiveLayer" + )); + + let mut transparency = hatch_command(); + assert!(matches!( + transparency.on_text_input("T"), + Some(CmdResult::NeedPoint) + )); + assert!(matches!( + transparency.on_text_input("91"), + Some(CmdResult::NeedPoint) + )); + assert!(matches!( + transparency.on_text_input("25"), + Some(CmdResult::HatcheditApply { + operation: HatchEditOperation::Appearance { + transparency: Some(Transparency::Explicit(64)), + .. + }, + .. + }) + )); + } + + #[test] + fn origin_choices_use_drawing_default_or_analytic_boundary_extents() { + let mut drawing_default = hatch_command(); + assert!(matches!( + drawing_default.on_text_input("O"), + Some(CmdResult::NeedPoint) + )); + assert!(matches!( + drawing_default.on_text_input("U"), + Some(CmdResult::HatcheditApply { + operation: HatchEditOperation::Update { + origin: Some((9.0, 8.0)), + store_origin: false, + .. + }, + .. + }) + )); + + let mut extents = hatch_command(); + assert!(matches!( + extents.on_text_input("O"), + Some(CmdResult::NeedPoint) + )); + assert!(matches!( + extents.on_text_input("D"), + Some(CmdResult::NeedPoint) + )); + assert!(matches!(extents.on_enter(), CmdResult::NeedPoint)); + assert!(matches!( + extents.on_text_input("Y"), + Some(CmdResult::HatcheditApply { + operation: HatchEditOperation::Update { + origin: Some((1.0, 2.0)), + store_origin: true, + .. + }, + .. + }) + )); + } + + #[test] + fn style_enter_keeps_the_selected_hatch_default() { + let mut command = hatch_command(); + assert!(matches!( + command.on_text_input("S"), + Some(CmdResult::NeedPoint) + )); + assert!(matches!( + command.on_enter(), + CmdResult::HatcheditApply { + operation: HatchEditOperation::Update { + style: Some(HatchStyleType::Outer), + .. + }, + .. + } + )); + } + + #[test] + fn full_ellipse_origin_uses_axis_anchor_bounds() { + let mut hatch = Hatch::new(); + let mut path = BoundaryPath::new(); + path.edges.push(BoundaryEdge::EllipticArc(EllipticArcEdge { + center: Vector2::new(10.0, 20.0), + major_axis_endpoint: Vector2::new(3.0, 4.0), + minor_axis_ratio: 0.4, + start_angle: 0.0, + end_angle: std::f64::consts::TAU, + counter_clockwise: true, + })); + hatch.paths.push(path); + let entity = EntityType::Hatch(hatch); + let mut command = HatcheditCommand::with_handle( + Handle::new(7), + "ANSI31".into(), + 1.0, + 0.0, + false, + ) + .with_appearance(Some(&entity), Color::ByLayer, Transparency::ByLayer); + + assert!(matches!( + command.on_text_input("O"), + Some(CmdResult::NeedPoint) + )); + assert!(matches!( + command.on_text_input("D"), + Some(CmdResult::NeedPoint) + )); + assert!(matches!(command.on_enter(), CmdResult::NeedPoint)); + assert!(matches!( + command.on_enter(), + CmdResult::HatcheditApply { + operation: HatchEditOperation::Update { + origin: Some((7.0, 16.0)), + .. + }, + .. + } + )); + } +} + // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["HATCHEDIT"] }); // HatcheditCommand 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/boundary.rs b/src/scene/boundary.rs index c04691960..780e8b731 100644 --- a/src/scene/boundary.rs +++ b/src/scene/boundary.rs @@ -628,6 +628,14 @@ pub(crate) fn boundary_entities_from_sources( } impl Scene { + pub(crate) fn replace_hatch_association(&mut self,handle:Handle,paths:Vec) { + let Some(EntityType::Hatch(hatch))=self.document.get_entity_mut(handle) else{return;}; + hatch.paths=paths; + hatch.is_associative=true; + self.associative_hatch_source_cache.borrow_mut().take(); + self.refresh_fill_model(handle); + } + pub(crate) fn edit_hatch_boundary_handles( &mut self, hatch_handle: Handle, diff --git a/src/scene/creation_style.rs b/src/scene/creation_style.rs index 442d61189..0a4a0fa29 100644 --- a/src/scene/creation_style.rs +++ b/src/scene/creation_style.rs @@ -252,7 +252,52 @@ fn apply_object_defaults(doc: &CadDocument, entity: &mut EntityType) { } pub fn apply_current_creation_styles(doc: &CadDocument, entity: &mut EntityType) { + entity.common_mut().transparency = doc.current_entity_transparency(); apply_text_defaults(doc, entity); apply_dimension_defaults(doc, entity); apply_object_defaults(doc, entity); } + +pub(crate) fn parse_current_transparency(value: &str) -> Option { + use acadrust::types::Transparency; + match value.trim().to_ascii_uppercase().as_str() { + "BYLAYER" | "-1" => Some(Transparency::ByLayer), + "BYBLOCK" | "-2" => Some(Transparency::ByBlock), + value => value.parse::().ok().filter(|value| *value <= 90) + .map(|value| Transparency::from_percent(value as f64 / 100.0)), + } +} + +pub(crate) fn current_transparency_label(value: acadrust::types::Transparency) -> String { + use acadrust::types::Transparency; + match value { + Transparency::ByLayer => "ByLayer".into(), + Transparency::ByBlock => "ByBlock".into(), + Transparency::Explicit(_) => format!("{:.0}", value.as_percent() * 100.0), + } +} + +#[cfg(test)] +mod transparency_tests { + use super::*; + use acadrust::types::Transparency; + + #[test] + fn parses_supported_current_transparency_values() { + assert_eq!(parse_current_transparency("ByLayer"), Some(Transparency::ByLayer)); + assert_eq!(parse_current_transparency("-1"), Some(Transparency::ByLayer)); + assert_eq!(parse_current_transparency("ByBlock"), Some(Transparency::ByBlock)); + assert_eq!(parse_current_transparency("-2"), Some(Transparency::ByBlock)); + assert_eq!(parse_current_transparency("25"), Some(Transparency::Explicit(64))); + assert!(parse_current_transparency("91").is_none()); + assert!(parse_current_transparency("25.5").is_none()); + assert!(parse_current_transparency("invalid").is_none()); + } + + #[test] + fn labels_transparency_for_command_and_properties_inputs() { + assert_eq!(current_transparency_label(Transparency::ByLayer), "ByLayer"); + assert_eq!(current_transparency_label(Transparency::ByBlock), "ByBlock"); + assert_eq!(current_transparency_label(Transparency::Explicit(64)), "25"); + } +} 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..26d122d7f 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1674,6 +1674,7 @@ impl Scene { let fill_plane_boundary = Some(Arc::new(fill_plane.1)); let fill_plane = Some(fill_plane.0); models.push(HatchModel { + pattern_origin: None, render_instance, boundary: Arc::new(boundary), boundary_wcs: None, @@ -2107,6 +2108,7 @@ impl Scene { let storage = crate::entities::curve::ocs_plane(dxf.normal, dxf.elevation); Some(HatchModel { + pattern_origin: Some([dxf.pattern_origin().x, dxf.pattern_origin().y]), render_instance: None, boundary: std::sync::Arc::new(boundary_f32), boundary_wcs: None, @@ -2429,6 +2431,7 @@ impl Scene { }) .collect(); HatchModel { + pattern_origin: None, render_instance: None, boundary: std::sync::Arc::new(boundary), boundary_wcs: None, @@ -2663,6 +2666,9 @@ impl Scene { // carries `world_origin: [0, 0]`, which after the world_offset removal // leaves the fill mis-placed and effectively invisible until a later // edit rebuilds it from the DXF — so keep the seed, don't overwrite it. + if let Some(origin) = model.pattern_origin { + dxf.record_pattern_origin(Vector2::new(origin[0], origin[1])); + } let mut entity = EntityType::Hatch(dxf); if let Some(layer) = layer { @@ -2671,6 +2677,8 @@ impl Scene { if let Some((color, transparency)) = entity_style { entity.common_mut().color = color; entity.common_mut().transparency = transparency; + } else { + entity.common_mut().transparency = self.document.current_entity_transparency(); } self.add_entity(entity) @@ -2691,6 +2699,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..4bfdd0864 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); } @@ -3794,6 +3856,7 @@ impl Scene { let ((x0, y0), (x1, y1)) = self.paper_limits()?; let (x0, y0, x1, y1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); Some(HatchModel { + pattern_origin: None, render_instance: None, world_origin: [0.0, 0.0], boundary: Arc::new(vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]), @@ -12046,7 +12109,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 +12133,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/hatch_model.rs b/src/scene/model/hatch_model.rs index 1ae480b4d..8165bcdbd 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -208,6 +208,8 @@ pub struct FillPlane { /// A hatched region defined by a closed polygon boundary. #[derive(Clone, Debug)] pub struct HatchModel { + /// Recorded hatch origin in its fill plane, independent of intrinsic PAT line bases. + pub pattern_origin: Option<[f64; 2]>, pub render_instance: Option, /// World XY anchor (in the same offset-relative coordinate space as /// the rest of the scene — `world_offset` already subtracted, but 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/model/presspull_model.rs b/src/scene/model/presspull_model.rs index 151b15e95..cd9361a94 100644 --- a/src/scene/model/presspull_model.rs +++ b/src/scene/model/presspull_model.rs @@ -141,7 +141,7 @@ fn boundary_sources(scene: &Scene, plane: WorkingPlane) -> FxHashMap, point: [f64; 2]) -> Option>> { +pub(crate) fn selected_rings(sources: &FxHashMap, point: [f64; 2]) -> Option>> { let rings = boundary_faces(sources, BOUNDARY_TOLERANCE); let loops = boundary_loops(sources, &rings)?; let tolerance = Tolerance::new(BOUNDARY_TOLERANCE); @@ -179,8 +179,12 @@ fn boundary_region( sources: &FxHashMap, rings: &[Vec<[f64; 2]>], plane: WorkingPlane, ) -> Option { let loops = boundary_loops(sources, rings)?; + region_from_loops(&loops, plane) +} + +pub(crate) fn region_from_loops(loops: &[Vec], plane: WorkingPlane) -> Option { let kernel_plane = Plane::from_axes(plane.origin.to_array(), plane.x.to_array(), plane.y.to_array()); - let sheet = brep::planar_region(kernel_plane, &loops)?; + let sheet = brep::planar_region(kernel_plane, loops)?; let document = crate::scene::convert::acis_export::solid_to_sat(&sheet)?; let mut region = Region::new(); region.acis_data = AcisData::from_sat(&document.to_sat_string()); 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/preview.rs b/src/scene/preview.rs index 93b91c9cf..20bd00d73 100644 --- a/src/scene/preview.rs +++ b/src/scene/preview.rs @@ -154,6 +154,7 @@ impl Scene { return None; } let mut model = HatchModel { + pattern_origin: None, render_instance: None, world_origin: origin, boundary: std::sync::Arc::new(boundary), 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/scene/view/render.rs b/src/scene/view/render.rs index 331a536f5..efde7319e 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -2886,6 +2886,7 @@ impl Scene { } boundary.push(boundary[0]); hatches.push(HatchModel { + pattern_origin: None, render_instance: wire.render_instance.clone(), world_origin: [0.0, 0.0], boundary: Arc::new(boundary), 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..507c150d6 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -131,6 +131,7 @@ impl canvas::Program for HatchPatternPreview { } HatchPattern::Pattern(_) => { let model = HatchModel { + pattern_origin: None, render_instance: None, world_origin: [0.0, 0.0], boundary: Arc::new(vec![ @@ -267,6 +268,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 +282,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 +354,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 +865,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 +1397,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 +1924,55 @@ fn render_ro_with_tooltip_row<'a>( prop_row_widget(label, wrapped.into()) } +// ── Constraints section row (clickable entity link) ─────────────────────── + +/// One persistent-constraint row in the Constraints properties section: +/// `label` is the glyph + kind name + resolved value (e.g. "↔ Distance: +/// hole_dia = 12.00"), rendered as the row's own clickable surface — there's +/// no natural separate "value" for a constraint row, unlike a Layer/Color +/// field, so this doesn't use the usual `prop_row_widget` label|value split. +/// Clicking it selects every entity in `handles`; a conflicting/redundant +/// constraint (mirrors the viewport glyph pill's own color cue) tints red. +fn render_entity_link_row<'a>(label: &'a str, handles: Vec, conflicting: bool) -> Element<'a, Message> { + let btn = button(text(label).size(FONT_SZ).width(Length::Fill)) + .on_press(Message::PropConstraintLinkClick(handles)) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let pair = if conflicting { + palette.danger.weak + } else { + match status { + button::Status::Hovered | button::Status::Pressed => palette.background.weak, + _ => palette.background.base, + } + }; + button::Style { + background: Some(Background::Color(pair.color)), + border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into() }, + text_color: pair.text, + ..Default::default() + } + }) + .padding([3, 8]) + .width(Length::Fill); + container(btn).width(Length::Fill).into() +} + +// ── Parameters section: "+ Add parameter" row ────────────────────────────── + +/// The trailing "+ Add parameter" row: appends a fresh, uniquely-named +/// parameter (`param1`, `param2`, …) the user then renames/redefines inline +/// — avoids a separate "pending new row" concept, since every row always +/// reflects a real committed table entry. +fn render_param_add_row<'a>() -> Element<'a, Message> { + let btn = button(row![text("+").size(FONT_SZ), text(t!("Add parameter").into_owned()).size(FONT_SZ)].spacing(6).align_y(iced::Center)) + .on_press(Message::PropParamAddNew) + .style(button::text) + .padding([4, 8]) + .width(Length::Fill); + container(btn).width(Length::Fill).into() +} + /// Build a label | widget property row. fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<'a, Message> { prop_row_with_active(label, widget, false) diff --git a/src/ui/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/block_hatch_export.rs b/tests/block_hatch_export.rs index 01f0a0f81..78b6696b0 100644 --- a/tests/block_hatch_export.rs +++ b/tests/block_hatch_export.rs @@ -240,6 +240,7 @@ fn app_created_hatch_roundtrips_catalog_spacing() { let mut scene = Scene::new(); let boundary: Vec<[f32; 2]> = vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; let model = HatchModel { + pattern_origin: None, render_instance: None, world_origin: [0.0, 0.0], boundary: Arc::new(boundary), @@ -298,6 +299,7 @@ fn nested_hatch_serializes_only_outer_as_external() { let boundary_f32: Vec<[f32; 2]> = wcs.iter().map(|&[x, y]| [x as f32, y as f32]).collect(); let model = HatchModel { + pattern_origin: None, render_instance: None, world_origin: [0.0, 0.0], boundary: Arc::new(boundary_f32), 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()); +}