diff --git a/Cargo.lock b/Cargo.lock index 6fa08c29a..93d22681c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -895,7 +895,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dfd0de176a878c20a1aeb8d23b0b8265197b5f5c#dfd0de176a878c20a1aeb8d23b0b8265197b5f5c" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=0507b720fef47bd7da0d4f777f97f1743a4f4038#0507b720fef47bd7da0d4f777f97f1743a4f4038" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 85dfe881b..34cc635e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ 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 = "dfd0de176a878c20a1aeb8d23b0b8265197b5f5c", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0507b720fef47bd7da0d4f777f97f1743a4f4038", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/assets/icons/boundary.svg b/assets/icons/boundary.svg new file mode 100644 index 000000000..61848abfd --- /dev/null +++ b/assets/icons/boundary.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/copy.svg b/assets/icons/copy.svg index 813f60544..f770b6cf6 100644 --- a/assets/icons/copy.svg +++ b/assets/icons/copy.svg @@ -1,4 +1,60 @@ - - - + + + + + + diff --git a/assets/icons/divide.svg b/assets/icons/divide.svg new file mode 100644 index 000000000..024546aac --- /dev/null +++ b/assets/icons/divide.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/donut.svg b/assets/icons/donut.svg new file mode 100644 index 000000000..8e2b26155 --- /dev/null +++ b/assets/icons/donut.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/erase.svg b/assets/icons/erase.svg index b1ecb37c9..279591f69 100644 --- a/assets/icons/erase.svg +++ b/assets/icons/erase.svg @@ -1,4 +1,58 @@ - - - + + + + + + diff --git a/assets/icons/extend.svg b/assets/icons/extend.svg index 8180b9f10..eed8f3444 100644 --- a/assets/icons/extend.svg +++ b/assets/icons/extend.svg @@ -6,7 +6,7 @@ version="1.1" id="svg4" sodipodi:docname="extend.svg" - inkscape:version="1.4.3 (0d15f75042, 2025-12-25)" + inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" @@ -23,9 +23,9 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#505050" showgrid="true" - inkscape:zoom="11.265625" - inkscape:cx="31.955617" - inkscape:cy="29.869626" + inkscape:zoom="7.258968" + inkscape:cx="30.58286" + inkscape:cy="34.92232" inkscape:window-width="1920" inkscape:window-height="939" inkscape:window-x="0" @@ -49,27 +49,27 @@ + style="stroke-width:2.45268;stroke-dasharray:none" /> + style="stroke-width:2.3545;stroke-dasharray:none" /> - - - + + + + + + - - - - - - - + + + + + - + diff --git a/assets/icons/helix.svg b/assets/icons/helix.svg new file mode 100644 index 000000000..bee0234bb --- /dev/null +++ b/assets/icons/helix.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/measure.svg b/assets/icons/measure.svg new file mode 100644 index 000000000..8ffa45c20 --- /dev/null +++ b/assets/icons/measure.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/multipoint.svg b/assets/icons/multipoint.svg new file mode 100644 index 000000000..a5443446e --- /dev/null +++ b/assets/icons/multipoint.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ray.svg b/assets/icons/ray.svg new file mode 100644 index 000000000..9a560c793 --- /dev/null +++ b/assets/icons/ray.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/region.svg b/assets/icons/region.svg new file mode 100644 index 000000000..fa9a6d339 --- /dev/null +++ b/assets/icons/region.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/spline_cv.svg b/assets/icons/spline_cv.svg new file mode 100644 index 000000000..e26e9c610 --- /dev/null +++ b/assets/icons/spline_cv.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/stretch.svg b/assets/icons/stretch.svg index b8bb66a58..2bdee9270 100644 --- a/assets/icons/stretch.svg +++ b/assets/icons/stretch.svg @@ -6,7 +6,7 @@ version="1.1" id="svg5" sodipodi:docname="stretch.svg" - inkscape:version="1.4.3 (0d15f75042, 2025-12-25)" + inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" @@ -22,9 +22,9 @@ inkscape:pageopacity="0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#505050" - inkscape:zoom="12.762909" - inkscape:cx="33.926435" - inkscape:cy="28.010856" + inkscape:zoom="11.968488" + inkscape:cx="25.984903" + inkscape:cy="32.794451" inkscape:window-width="1920" inkscape:window-height="939" inkscape:window-x="0" @@ -48,12 +48,12 @@ visible="true" /> @@ -61,8 +61,8 @@ diff --git a/assets/icons/trim.svg b/assets/icons/trim.svg index abf2fb07f..7950e8884 100644 --- a/assets/icons/trim.svg +++ b/assets/icons/trim.svg @@ -6,7 +6,7 @@ version="1.1" id="svg6" sodipodi:docname="trim.svg" - inkscape:version="1.4.3 (0d15f75042, 2025-12-25)" + inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" @@ -22,9 +22,9 @@ inkscape:pageopacity="0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#505050" - inkscape:zoom="11.265625" - inkscape:cx="31.955617" - inkscape:cy="32" + inkscape:zoom="6.1823588" + inkscape:cx="16.498557" + inkscape:cy="24.181709" inkscape:window-width="1920" inkscape:window-height="939" inkscape:window-x="0" @@ -32,32 +32,32 @@ inkscape:window-maximized="1" inkscape:current-layer="svg6" /> + style="stroke-width:2.62057;stroke-dasharray:none" /> + style="stroke-width:2.62057;stroke-dasharray:none" /> diff --git a/assets/icons/xline.svg b/assets/icons/xline.svg new file mode 100644 index 000000000..7b02665ef --- /dev/null +++ b/assets/icons/xline.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/ocs_doc_api/ARCHITECTURE.md b/crates/ocs_doc_api/ARCHITECTURE.md index 7d4a34f73..8603f8a10 100644 --- a/crates/ocs_doc_api/ARCHITECTURE.md +++ b/crates/ocs_doc_api/ARCHITECTURE.md @@ -4,6 +4,12 @@ `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 @@ -22,14 +28,18 @@ do not reorder them. The consistency tests verify their bincode discriminants. 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. - -Extension workflow: - -1. Add the operation/query DTO and its name mapping, then update `spec/entities.toml`. -2. Implement validation, backend preparation, executor dispatch and facade methods. -3. Preserve each profile's plane and use cadkernel for geometry. -4. Test native behavior, failed-operation atomicity and the binding transport payload. -5. Run `cargo test -p ocs_doc_api --all-features` and rebuild the generated snapshots. diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index 592effe98..744d3559e 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -15,7 +15,7 @@ thiserror = "1" # Host adapters convert ObjectId to acadrust handles; kernel handles stay local. acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dfd0de176a878c20a1aeb8d23b0b8265197b5f5c", features = ["acis", "offset"], 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] @@ -34,6 +34,8 @@ default = [] 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" diff --git a/crates/ocs_doc_api/README.md b/crates/ocs_doc_api/README.md index bd1f0289a..cc38a56ca 100644 --- a/crates/ocs_doc_api/README.md +++ b/crates/ocs_doc_api/README.md @@ -30,11 +30,109 @@ 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 diff --git a/crates/ocs_doc_api/bindings/README.md b/crates/ocs_doc_api/bindings/README.md index 13f351c75..deb13c55d 100644 --- a/crates/ocs_doc_api/bindings/README.md +++ b/crates/ocs_doc_api/bindings/README.md @@ -21,8 +21,39 @@ 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. 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. +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 index 53259fb15..d361493ba 100644 --- a/crates/ocs_doc_api/bindings/python/generate.py +++ b/crates/ocs_doc_api/bindings/python/generate.py @@ -36,6 +36,12 @@ class Entity: 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 = ''' @@ -79,6 +85,18 @@ class Document: 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 diff --git a/crates/ocs_doc_api/build.rs b/crates/ocs_doc_api/build.rs index 74c522413..c951f0d66 100644 --- a/crates/ocs_doc_api/build.rs +++ b/crates/ocs_doc_api/build.rs @@ -168,9 +168,32 @@ Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, - **`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) and `as_solid()` (typed -downcast when `kind == "Solid3D"`). +`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 diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index 363841d44..8b7b7454b 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -8,11 +8,12 @@ use std::collections::HashMap; use ocs_doc_api::backend::{DocApiBackend, KernelBody}; use ocs_doc_api::{ - Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, - ObjectId, PlacementSpec, + normalize_layer_name, Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, + GeometryRevision, LayerInfo, ObjectId, PlacementSpec, }; -#[derive(Default, Clone)] + +#[derive(Clone)] pub struct MockBackend { next_id: u64, revision: u64, @@ -31,6 +32,38 @@ pub struct MockBackend { 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 { @@ -52,7 +85,7 @@ impl MockBackend { /// 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 { + Curve2Spec::Line { start, end, .. } => Aabb { min: [ start[0].min(end[0]), start[1].min(end[1]), @@ -64,11 +97,11 @@ fn curve_bounds(spec: &Curve2Spec) -> Aabb { start[2].max(end[2]), ], }, - Curve2Spec::Circle { centre, radius } => Aabb { + 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 { + Curve2Spec::Point { position, .. } => Aabb { min: *position, max: *position, }, @@ -133,6 +166,15 @@ impl DocApiBackend for MockBackend { 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() @@ -173,10 +215,29 @@ impl DocApiBackend for MockBackend { 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")); @@ -355,6 +416,144 @@ impl DocApiBackend for MockBackend { .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(()) @@ -364,17 +563,85 @@ impl DocApiBackend for MockBackend { } 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, bounds }) + Ok(EntityView { + id, + kind, + layer, + bounds, + }) } fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { if !self.entity_exists(id) { @@ -423,6 +690,9 @@ impl DocApiBackend for MockBackend { 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) } diff --git a/crates/ocs_doc_api/examples/e5_failure_and_guards.rs b/crates/ocs_doc_api/examples/e5_failure_and_guards.rs index f86db5132..2cc82dbe5 100644 --- a/crates/ocs_doc_api/examples/e5_failure_and_guards.rs +++ b/crates/ocs_doc_api/examples/e5_failure_and_guards.rs @@ -54,10 +54,7 @@ fn main() -> ocs_doc_api::ApiResult<()> { } // A totally unknown id also surfaces a structured error. - assert!(matches!( - doc.entities().get(ObjectId::from_u64(0xDEAD)), - Err(_) - )); + 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 index b885693dd..4de4cf6eb 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -457,6 +457,35 @@ 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 # ============================================================================ @@ -469,6 +498,42 @@ op = "CreateTable" # - 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" diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 5a857db8a..51e4cfafe 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -5,7 +5,7 @@ use crate::error::{ApiError, ApiResult}; use crate::id::ObjectId; -use crate::ops::Curve2Spec; +use crate::ops::{Curve2Spec, LayerInfo}; use crate::query::{Aabb, EntityView}; use crate::revision::GeometryRevision; @@ -152,6 +152,85 @@ pub trait DocApiBackend { 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). @@ -178,6 +257,10 @@ pub trait DocApiBackend { } } + /// 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; diff --git a/crates/ocs_doc_api/src/convert.rs b/crates/ocs_doc_api/src/convert.rs index 4f91cb4c4..db1f0a22f 100644 --- a/crates/ocs_doc_api/src/convert.rs +++ b/crates/ocs_doc_api/src/convert.rs @@ -113,19 +113,19 @@ pub fn bulge_arc_segment( pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { crate::validation::curve(spec)?; let entity = match spec { - Curve2Spec::Line { start, end } => { + Curve2Spec::Line { start, end, .. } => { let mut e = Line::new(); e.start = v3(*start); e.end = v3(*end); EntityType::Line(e) } - Curve2Spec::Circle { centre, radius } => { + Curve2Spec::Circle { centre, radius, .. } => { let mut e = Circle::new(); e.center = v3(*centre); e.radius = *radius; EntityType::Circle(e) } - Curve2Spec::Polyline { points, closed } => { + Curve2Spec::Polyline { points, closed, .. } => { let mut e = LwPolyline::new(); e.vertices = points .iter() @@ -147,7 +147,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { e.is_closed = *closed; EntityType::LwPolyline(e) } - Curve2Spec::Point { position } => { + Curve2Spec::Point { position, .. } => { let mut e = Point::new(); e.location = v3(*position); EntityType::Point(e) @@ -157,6 +157,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { radius, start_angle, end_angle, + .. } => { let mut e = Arc::new(); e.center = v3(*centre); @@ -171,6 +172,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { ratio, start, end, + .. } => { // Validate the minor/major ratio so the (major-axis) bounds never // under-bound: ratio must be in (0, 1]. @@ -193,6 +195,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { control_points, knots, weights, + .. } => { let mut e = Spline::new(); e.degree = *degree; @@ -201,10 +204,10 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { e.weights = weights.clone(); EntityType::Spline(e) } - Curve2Spec::Ray { origin, direction } => { + Curve2Spec::Ray { origin, direction, .. } => { EntityType::Ray(Ray::new(v3(*origin), v3(*direction))) } - Curve2Spec::XLine { origin, direction } => { + Curve2Spec::XLine { origin, direction, .. } => { EntityType::XLine(XLine::new(v3(*origin), v3(*direction))) } }; diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index 5f8cbce0f..423fc0658 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -25,14 +25,35 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult(b: &mut B, op: Operation) -> ApiResult { let name = op.op_name(); crate::validation::operation(&op)?; - if let Operation::TransformMany { ids, .. } | Operation::DeleteMany(ids) = &op { - 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", - )); + 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 { @@ -353,6 +374,73 @@ fn apply_op_inner(b: &mut B, op: Operation) -> ApiResult { + 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), @@ -417,6 +505,38 @@ pub fn apply_queries(b: &mut B, queries: Vec) -> ApiRes 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); } @@ -459,6 +579,15 @@ fn require_exists(b: &B, id: ObjectId, op: &'static str) -> Ap } } +/// 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}")) } diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 97cc4d0d6..ef6f2b13d 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -13,7 +13,7 @@ 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}; +use crate::ops::{BoolOp, Curve2Spec, PlacementSpec, SolidPrimitive, XDataRecord, XRecordSpec}; use crate::query::{Aabb, EntityView, QueryResult}; use crate::revision::GeometryRevision; use crate::transport::Transport; @@ -170,6 +170,74 @@ impl Document { } } + /// 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. @@ -318,6 +386,12 @@ macro_rules! handle { 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 })? { @@ -336,24 +410,62 @@ macro_rules! handle { })?; 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(()) + } } }; } -handle!(Entity); -handle!(Solid); -handle!(Line); -handle!(Circle); -handle!(Polyline); -handle!(Point); -handle!(ArcCurve); -handle!(Ellipse); -handle!(Spline); -handle!(Ray); -handle!(XLine); -handle!(Text); -handle!(MText); -handle!(Dimension); +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 @@ -416,6 +528,26 @@ impl Entity { })?; 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") @@ -621,22 +753,80 @@ impl CurveCollection { 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 })?; + 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 })?; + 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 })?; + 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( @@ -651,6 +841,24 @@ impl CurveCollection { 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)) } @@ -668,6 +876,26 @@ impl CurveCollection { 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)) } @@ -683,15 +911,67 @@ impl CurveCollection { 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 })?; + 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 })?; + 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). @@ -874,10 +1154,42 @@ impl CurveCollection { 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(Curve2Spec::Point { position: p })) + .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 @@ -1001,6 +1313,41 @@ impl EntityCollection { 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 { diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 20cb2a0d6..a0396caa1 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -192,6 +192,30 @@ Table: create_table(insertion_point, data[row][col]) builds rows x columns of te - **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`, @@ -204,9 +228,32 @@ Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, - **`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) and `as_solid()` (typed -downcast when `kind == "Solid3D"`). +`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 @@ -284,6 +331,14 @@ Every `Operation` is ONE atomic write op (one undo step); every `Query` is read- - `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` @@ -299,6 +354,10 @@ Every `Operation` is ONE atomic write op (one undo step); every `Query` is read- - `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`) diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 90f422758..04697d696 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -1124,6 +1124,128 @@ "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": { @@ -1164,7 +1286,15 @@ "CreateDimensionAngular(DimensionAngularSpec)", "CreateAttributeDefinition(AttributeDefinitionSpec)", "CreateTable(TableSpec)", - "CreateDimensionAngular2Ln(DimensionAngularSpec)" + "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": { @@ -1180,7 +1310,11 @@ "GetDimensionMeasurement { id: ObjectId }", "GetAttributes { id: ObjectId }", "GetBlockEntities { block_name: String }", - "GetViewportView { id: ObjectId }" + "GetViewportView { id: ObjectId }", + "GetXData { id: ObjectId, application_name: String }", + "GetXRecord { id: ObjectId }", + "ListLayers", + "GetEntityLayer { id: ObjectId }" ] } }, @@ -1205,7 +1339,9 @@ "XLine", "Text", "MText", - "Dimension" + "Dimension", + "XRecord", + "Layer" ], "root": "DocApi" }, diff --git a/crates/ocs_doc_api/src/gen/layouts.json b/crates/ocs_doc_api/src/gen/layouts.json index c743ba25d..b43755336 100644 --- a/crates/ocs_doc_api/src/gen/layouts.json +++ b/crates/ocs_doc_api/src/gen/layouts.json @@ -29,7 +29,15 @@ "CreateDimensionAngular(DimensionAngularSpec)", "CreateAttributeDefinition(AttributeDefinitionSpec)", "CreateTable(TableSpec)", - "CreateDimensionAngular2Ln(DimensionAngularSpec)" + "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": { @@ -45,7 +53,11 @@ "GetDimensionMeasurement { id: ObjectId }", "GetAttributes { id: ObjectId }", "GetBlockEntities { block_name: String }", - "GetViewportView { id: ObjectId }" + "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 index 22ca691a3..7d8e91d22 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -10,7 +10,7 @@ use crate::id::ObjectId; use crate::ops::{ AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec, DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, - SolidPrimitive, TableSpec, TextSpec, ViewportSpec, + SolidPrimitive, TableSpec, TextSpec, ViewportSpec, XDataRecord, XRecordSpec, }; /// A typed write operation. Each variant is ONE atomic host call = one @@ -99,4 +99,29 @@ pub enum Operation { 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 index efefde74a..f58c6a0c8 100644 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ b/crates/ocs_doc_api/src/gen/query_gen.rs @@ -55,4 +55,35 @@ pub enum Query { 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/layer.rs b/crates/ocs_doc_api/src/layer.rs new file mode 100644 index 000000000..48987f24f --- /dev/null +++ b/crates/ocs_doc_api/src/layer.rs @@ -0,0 +1,42 @@ +//! 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 index 4949b82d6..649f4bc84 100644 --- a/crates/ocs_doc_api/src/lib.rs +++ b/crates/ocs_doc_api/src/lib.rs @@ -17,6 +17,7 @@ mod validation; pub mod envelope; pub mod facade; +pub mod layer; pub mod ops; pub mod query; pub mod transport; @@ -41,9 +42,14 @@ 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, + 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 ops::{BoolOp, Curve2Spec, EntitySpec, Operation, PlacementSpec, SolidPrimitive}; pub use query::{Aabb, EntityView, Query, QueryResult}; #[cfg(feature = "host")] @@ -51,6 +57,9 @@ 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`). diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index c41d7cb1c..7941b4112 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -8,6 +8,80 @@ 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 { @@ -16,6 +90,72 @@ pub enum BoolOp { 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)] @@ -55,17 +195,25 @@ 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 { @@ -73,6 +221,8 @@ pub enum Curve2Spec { 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π). @@ -82,6 +232,8 @@ pub enum Curve2Spec { 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 { @@ -89,19 +241,42 @@ pub enum Curve2Spec { 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. @@ -111,6 +286,16 @@ pub enum EntitySpec { 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). @@ -291,6 +476,14 @@ impl crate::gen::Operation { 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 index 4ce1b1ba5..e53d2b650 100644 --- a/crates/ocs_doc_api/src/query.rs +++ b/crates/ocs_doc_api/src/query.rs @@ -41,6 +41,8 @@ pub struct EntityView { /// 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). @@ -68,6 +70,20 @@ pub enum QueryResult { 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. @@ -87,6 +103,13 @@ impl crate::gen::Query { 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/transport/ipc.rs b/crates/ocs_doc_api/src/transport/ipc.rs index 74bd4f06f..f9b4b1d3e 100644 --- a/crates/ocs_doc_api/src/transport/ipc.rs +++ b/crates/ocs_doc_api/src/transport/ipc.rs @@ -5,11 +5,12 @@ use std::sync::Arc; -use ocs_plugin_api::host::{PluginRequestError, PluginRequestSender}; +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`; @@ -29,6 +30,21 @@ impl OcsPluginApiIpc { } } +/// 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) @@ -56,3 +72,128 @@ impl Transport for OcsPluginApiIpc { } } } + +#[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/validation.rs b/crates/ocs_doc_api/src/validation.rs index 8a96a3710..dc9ca9909 100644 --- a/crates/ocs_doc_api/src/validation.rs +++ b/crates/ocs_doc_api/src/validation.rs @@ -1,5 +1,5 @@ //! Reject invalid numeric inputs before the backend starts an undo operation. -use crate::{ops::*, ApiError, ApiResult}; +use crate::{id::ObjectId, ops::*, ApiError, ApiResult}; fn finite(values: &[f64]) -> bool { values.iter().all(|v| v.is_finite()) @@ -21,19 +21,102 @@ fn check(valid: bool, op: &'static str) -> ApiResult<()> { } } +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), + 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 } => { + 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]) @@ -44,6 +127,7 @@ pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> { ratio, start, end, + .. } => { finite(centre) && direction(major_axis) @@ -56,6 +140,7 @@ pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> { control_points, knots, weights, + .. } => { *degree > 0 && control_points.len() <= BULK_ITEM_CAP @@ -76,10 +161,12 @@ pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> { Ray { origin, direction: d, + .. } | XLine { origin, direction: d, + .. } => finite(origin) && direction(d), }; check(valid, "CreateCurve") @@ -111,6 +198,36 @@ pub(crate) fn operation(op: &Operation) -> ApiResult<()> { } 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)) } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 7983d7ad8..d9059bbde 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -8,15 +8,16 @@ use std::sync::Arc; use ocs_doc_api::backend::{DocApiBackend, KernelBody}; use ocs_doc_api::{ - Aabb, ApiError, ApiResult, Curve2Spec, DocApi, EntityView, GeometryErrorKind, GeometryRevision, - HasId, ObjectId, PlacementSpec, + 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 { + Curve2Spec::Line { start, end, .. } => Aabb { min: [ start[0].min(end[0]), start[1].min(end[1]), @@ -28,11 +29,12 @@ fn curve_bounds(spec: &Curve2Spec) -> Aabb { start[2].max(end[2]), ], }, - Curve2Spec::Circle { centre, radius } | Curve2Spec::Arc { centre, radius, .. } => Aabb { + 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 { + Curve2Spec::Point { position, .. } => Aabb { min: *position, max: *position, }, @@ -72,13 +74,17 @@ fn curve_bounds(spec: &Curve2Spec) -> Aabb { /// In-memory mock backend: HashMap + a tiny entity table. /// Tracks undo steps + revision bumps to assert per-op semantics. -#[derive(Default, Clone)] +#[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. @@ -92,6 +98,36 @@ struct MockBackend { 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 { @@ -104,6 +140,22 @@ impl MockBackend { 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 { @@ -113,6 +165,7 @@ impl DocApiBackend for MockBackend { 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<()> { @@ -122,6 +175,15 @@ impl DocApiBackend for MockBackend { 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() @@ -151,26 +213,35 @@ impl DocApiBackend for MockBackend { 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 id = self.alloc(kind); - self.curves.insert(id, spec.clone()); + 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")); } - Ok(self.alloc("Insert")) + 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"); @@ -181,11 +252,13 @@ impl DocApiBackend for MockBackend { 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<()> { @@ -206,6 +279,7 @@ impl DocApiBackend for MockBackend { 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>> { @@ -224,6 +298,7 @@ impl DocApiBackend for MockBackend { + (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( @@ -236,13 +311,16 @@ impl DocApiBackend for MockBackend { + (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 { - self.add_dimension_radius(spec) + let id = self.add_dimension_radius(spec)?; + self.entity_layers.insert(id, "0".to_string()); + Ok(id) } fn add_dimension_angular( &mut self, @@ -250,6 +328,7 @@ impl DocApiBackend for MockBackend { ) -> 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 { @@ -286,7 +365,9 @@ impl DocApiBackend for MockBackend { "table needs a non-empty rectangular grid", )); } - Ok(self.alloc("Table")) + 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) { @@ -313,7 +394,9 @@ impl DocApiBackend for MockBackend { &mut self, _spec: &ocs_doc_api::ops::RasterImageSpec, ) -> ApiResult { - Ok(self.alloc("RasterImage")) + let id = self.alloc("RasterImage"); + self.entity_layers.insert(id, "0".to_string()); + Ok(id) } fn loft( &mut self, @@ -345,6 +428,203 @@ impl DocApiBackend for MockBackend { .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(()) @@ -354,6 +634,9 @@ impl DocApiBackend for MockBackend { } 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 { @@ -364,7 +647,17 @@ impl DocApiBackend for MockBackend { max: bb.max, }) }); - Ok(EntityView { id, kind, bounds }) + 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) { @@ -412,6 +705,9 @@ impl DocApiBackend for MockBackend { 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) } @@ -609,3 +905,178 @@ fn typed_handles_reject_foreign_sessions_and_tabs() { 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 index f65c3a63f..d7fc6c3dd 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -38,7 +38,8 @@ fn spec() -> Spec { /// The variant names of the `Operation` enum, in declaration order. fn operation_variants() -> Vec { use ocs_doc_api::ops::{ - BoolOp, Curve2Spec, InsertSpec, Operation, PlacementSpec, SolidPrimitive, ViewportSpec, + BoolOp, Curve2Spec, InsertSpec, LayerInfo, Operation, PlacementSpec, SolidPrimitive, + ViewportSpec, }; use ocs_doc_api::ObjectId; let _marker: Vec = vec![ @@ -46,7 +47,10 @@ fn operation_variants() -> Vec { centre: [0.0; 3], radius: 1.0, }), - Operation::CreateCurve(Curve2Spec::Point { position: [0.0; 3] }), + Operation::CreateCurve(Curve2Spec::Point { + position: [0.0; 3], + layer: None, + }), Operation::Extrude { profile: ObjectId::from_u64(0), direction: [0.0; 3], @@ -167,6 +171,37 @@ fn operation_variants() -> Vec { 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() @@ -178,7 +213,7 @@ fn operation_variants() -> Vec { index ); let dbg = format!("{:?}", op); - dbg.split(|c: char| c == '(' || c == ' ' || c == '{') + dbg.split(['(', ' ', '{']) .next() .unwrap_or("") .to_string() @@ -226,6 +261,17 @@ fn query_variants() -> Vec { 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() @@ -237,7 +283,7 @@ fn query_variants() -> Vec { index ); let dbg = format!("{:?}", q); - dbg.split(|c: char| c == '(' || c == ' ' || c == '{') + dbg.split(['(', ' ', '{']) .next() .unwrap_or("") .to_string() @@ -309,6 +355,14 @@ const OPERATION_BASELINE: &[&str] = &[ "CreateAttributeDefinition", "CreateTable", "CreateDimensionAngular2Ln", + "SetXData", + "SetXDataMany", + "CreateXRecord", + "SetXRecord", + "CreateLayer", + "UpdateLayer", + "DeleteLayer", + "SetEntityLayer", ]; /// Recorded baseline of the `Query` variant order. @@ -325,6 +379,10 @@ const QUERY_BASELINE: &[&str] = &[ "GetAttributes", "GetBlockEntities", "GetViewportView", + "GetXData", + "GetXRecord", + "ListLayers", + "GetEntityLayer", ]; #[test] diff --git a/index.html b/index.html index 1e0216af1..b8b83ce5d 100644 --- a/index.html +++ b/index.html @@ -28,6 +28,7 @@ + {{website_schema}} diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index fe5d8961f..6dd0fd5c2 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1388,6 +1388,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = يتحكم في مربع التحديد المرئي ومنطقة النقر معًا. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = التطبيقات + .installed-plugins-and-their-sources = الإضافات المثبتة ومصادرها + .plugins-2 = الإضافات… + .keyboard-shortcuts-2 = اختصارات لوحة المفاتيح + .keyboard-shortcuts-3 = اختصارات لوحة المفاتيح… + .command-aliases-2 = الأسماء المستعارة للأوامر + .command-aliases-3 = الأسماء المستعارة للأوامر… + .file-safety = أمان الملفات + .automatic-save = الحفظ التلقائي + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = الدقائق بين عمليات حفظ الاسترداد في ملف ‎.sv$‎؛ القيمة 0 تعطّله (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = الاحتفاظ بنسخة ‎.bak‎ عند استبدال رسم (ISAVEBAK) + .plotting = الطباعة + .plot-device-paper-scale-and-plot-styles = جهاز الطباعة والورق والمقياس وأنماط الطباعة + .plot-and-page-setup = الطباعة وإعداد الصفحة… + .fill-truetype-glyphs-textfill = ملء محارف TrueType (TEXTFILL) + .command-line = سطر الأوامر + .prompt-lines = أسطر المطالبة + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = أسطر المطالبة المؤقتة أعلى نافذة الأوامر (CLIPROMPTLINES). + .history-fade-time = زمن تلاشي السجل + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = مدة بقاء أسطر السجل ظاهرة؛ القيمة 0 تتخطاها (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = النقر على الكائنات المتداخلة يفتح أداة اختيار + .block-edit = تحرير الكتلة + .double-click-to-edit-block-in-place-refedit = النقر المزدوج يحرر الكتلة في مكانها (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = النقر المزدوج على الكتل ذات السمات يحرر سماتها (ATTEDIT) + .user-preferences = تفضيلات المستخدم + .zoom = التكبير + .reverse-mouse-wheel-zoom-zoomwheel = عكس التكبير بعجلة الفأرة (ZOOMWHEEL) + .zoom-factor = معامل التكبير + .how-far-one-wheel-notch-zooms-zoomfactor = مقدار التكبير لكل درجة من العجلة (ZOOMFACTOR). + .text-and-dimensions = النص والأبعاد + .textedit-edits-one-object-and-ends-texteditmode = يحرر TEXTEDIT كائنًا واحدًا ثم ينتهي (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = الأبعاد المتصلة ترث طبقة ونمط البعد الأساسي (DIMCONTINUEMODE) + .qdim-origin-priority = أولوية أصل QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = النقاط التي يقيس منها QDIM. يمكن ضبطها داخل الأمر أيضًا. + .add-scales-automatically = إضافة المقاييس تلقائيًا + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = الكائنات التوضيحية التي تتبنى مقياس توضيح تم ضبطه حديثًا (ANNOAUTOSCALE). + .length-angle-and-insertion-units = وحدات الطول والزاوية والإدراج + .drawing-units-2 = وحدات الرسم… + .drafting = الرسم + .drafting-rotation = دوران الرسم + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = يدير الشعيرات المتقاطعة وشبكة الالتقاط في نظام الإحداثيات النشط (SNAPANG). + .polar-tracking-increment = زيادة التتبع القطبي + .snap-and-grid = الالتقاط والشبكة + .snap-modes-grid-spacing-and-object-snap = أوضاع الالتقاط وتباعد الشبكة والتقاط الكائنات + .drafting-settings-2 = إعدادات الرسم… + .modeling-3d = النمذجة ثلاثية الأبعاد + .display-tools = أدوات العرض + .show-the-navigation-cube-navvcube = إظهار مكعب التنقل (NAVVCUBE) + .show-the-ucs-icon-ucsicon = إظهار أيقونة نظام الإحداثيات (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = وضع أيقونة نظام الإحداثيات عند نقطة الأصل (UCSICON ORigin) + .display-resolution = دقة العرض + .applies-to-the-current-drawing = ينطبق على الرسم الحالي + .isolines-per-surface = خطوط التساوي لكل سطح + .show-silhouette-edges-on-solids-dispsilh = إظهار حواف الظل الخارجي على المجسمات (DISPSILH) + .surface-density-u = كثافة السطح U + .surface-density-v = كثافة السطح V + .surface-type = نوع السطح + .record-the-history-of-composite-solids-solidhist = تسجيل تاريخ المجسمات المركبة (SOLIDHIST) + .show-solid-history = إظهار تاريخ المجسمات + .not-available = غير متاح + .open-folder = فتح المجلد + .where-the-application-keeps-its-own-files-these-locations-are-fixed = المكان الذي يحفظ فيه التطبيق ملفاته الخاصة. هذه المواقع ثابتة. + .configuration = التهيئة + .plot-styles = أنماط الطباعة + .autosave-files = ملفات الحفظ التلقائي .selection-modes = أوضاع التحديد .use-shift-to-add-to-selection-pickadd = استخدام Shift للإضافة إلى التحديد (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = الضغط والسحب يرسم مستطيلاً بدلاً من التحديد الحر (PICKDRAG) diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 01b8c9683..cb93e9870 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1375,6 +1375,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Управлява видимото поле за избор и областта за щракване. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Приложения + .installed-plugins-and-their-sources = Инсталирани добавки и техните източници + .plugins-2 = Добавки… + .keyboard-shortcuts-2 = Клавишни комбинации + .keyboard-shortcuts-3 = Клавишни комбинации… + .command-aliases-2 = Псевдоними на команди + .command-aliases-3 = Псевдоними на команди… + .file-safety = Безопасност на файловете + .automatic-save = Автоматично записване + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Минути между възстановителните записи във файл .sv$; 0 го изключва (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Запазване на копие .bak при презаписване на чертеж (ISAVEBAK) + .plotting = Чертане + .plot-device-paper-scale-and-plot-styles = Устройство за чертане, хартия, мащаб и стилове на чертане + .plot-and-page-setup = Чертане и настройка на страница… + .fill-truetype-glyphs-textfill = Запълване на глифове TrueType (TEXTFILL) + .command-line = Команден ред + .prompt-lines = Редове с подкана + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Временни редове с подкана над командния прозорец (CLIPROMPTLINES). + .history-fade-time = Време на избледняване на историята + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Колко дълго остават видими редовете от историята; 0 ги пропуска (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Щракването върху припокриващи се обекти отваря избирач + .block-edit = Редактиране на блок + .double-click-to-edit-block-in-place-refedit = Двойно щракване редактира блока на място (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Двойно щракване върху блокове с атрибути редактира атрибутите (ATTEDIT) + .user-preferences = Потребителски предпочитания + .zoom = Мащабиране + .reverse-mouse-wheel-zoom-zoomwheel = Обръщане на мащабирането с колелцето (ZOOMWHEEL) + .zoom-factor = Коефициент на мащабиране + .how-far-one-wheel-notch-zooms-zoomfactor = Колко мащабира едно щракване на колелцето (ZOOMFACTOR). + .text-and-dimensions = Текст и размери + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT редактира един обект и приключва (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Продължените размери наследяват слоя и стила на базовия размер (DIMCONTINUEMODE) + .qdim-origin-priority = Приоритет на началото на QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = От кои точки мери QDIM. Настройва се и в самата команда. + .add-scales-automatically = Автоматично добавяне на мащаби + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Кои анотативни обекти приемат новозададен мащаб на анотация (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Единици за дължина, ъгъл и вмъкване + .drawing-units-2 = Единици на чертежа… + .drafting = Чертане на ръка + .drafting-rotation = Завъртане на чертането + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Завърта мерника и мрежата за прилепване в активната ПКС (SNAPANG). + .polar-tracking-increment = Стъпка на полярно проследяване + .snap-and-grid = Прилепване и мрежа + .snap-modes-grid-spacing-and-object-snap = Режими на прилепване, стъпка на мрежата и прилепване към обекти + .drafting-settings-2 = Настройки на чертането… + .modeling-3d = 3D моделиране + .display-tools = Инструменти за изглед + .show-the-navigation-cube-navvcube = Показвай навигационния куб (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Показвай иконата на ПКС (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Поставяй иконата на ПКС в началото (UCSICON ORigin) + .display-resolution = Разделителна способност на изгледа + .applies-to-the-current-drawing = отнася се за текущия чертеж + .isolines-per-surface = Изолинии на повърхнина + .show-silhouette-edges-on-solids-dispsilh = Показвай силуетни ръбове на телата (DISPSILH) + .surface-density-u = Плътност на повърхнината U + .surface-density-v = Плътност на повърхнината V + .surface-type = Тип повърхнина + .record-the-history-of-composite-solids-solidhist = Записвай историята на съставните тела (SOLIDHIST) + .show-solid-history = Показвай историята на телата + .not-available = Не е налично + .open-folder = Отвори папката + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Къде приложението държи собствените си файлове. Тези места са фиксирани. + .configuration = Конфигурация + .plot-styles = Стилове на чертане + .autosave-files = Файлове за автоматично записване .selection-modes = Режими на избор .use-shift-to-add-to-selection-pickadd = Използвай Shift за добавяне към избора (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Натискане и влачене чертае правоъгълник вместо ласо (PICKDRAG) diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 1c4332caa..16f1737ec 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1375,6 +1375,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Ovládá viditelný výběrový rámeček i oblast kliknutí. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Aplikace + .installed-plugins-and-their-sources = Nainstalované zásuvné moduly a jejich zdroje + .plugins-2 = Zásuvné moduly… + .keyboard-shortcuts-2 = Klávesové zkratky + .keyboard-shortcuts-3 = Klávesové zkratky… + .command-aliases-2 = Aliasy příkazů + .command-aliases-3 = Aliasy příkazů… + .file-safety = Bezpečnost souborů + .automatic-save = Automatické ukládání + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minuty mezi obnovovacími uloženími do souboru .sv$; 0 je vypne (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Při přepsání výkresu ponechat kopii .bak (ISAVEBAK) + .plotting = Vykreslování + .plot-device-paper-scale-and-plot-styles = Vykreslovací zařízení, papír, měřítko a styly vykreslení + .plot-and-page-setup = Vykreslit a nastavení stránky… + .fill-truetype-glyphs-textfill = Vyplňovat glyfy TrueType (TEXTFILL) + .command-line = Příkazový řádek + .prompt-lines = Řádky výzvy + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Dočasné řádky výzvy nad příkazovým oknem (CLIPROMPTLINES). + .history-fade-time = Doba mizení historie + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Jak dlouho zůstávají řádky historie viditelné; 0 je přeskočí (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Kliknutí na překrývající se objekty otevře výběr + .block-edit = Úprava bloku + .double-click-to-edit-block-in-place-refedit = Dvojklik upraví blok na místě (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Dvojklik na bloky s atributy upraví atributy (ATTEDIT) + .user-preferences = Předvolby uživatele + .zoom = Zoom + .reverse-mouse-wheel-zoom-zoomwheel = Obrátit zoom kolečkem myši (ZOOMWHEEL) + .zoom-factor = Faktor zoomu + .how-far-one-wheel-notch-zooms-zoomfactor = O kolik přiblíží jeden zub kolečka (ZOOMFACTOR). + .text-and-dimensions = Text a kóty + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT upraví jeden objekt a skončí (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Navazující kóty dědí hladinu a styl základní kóty (DIMCONTINUEMODE) + .qdim-origin-priority = Priorita počátku QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = Ze kterých bodů QDIM měří. Nastavitelné i uvnitř příkazu. + .add-scales-automatically = Přidávat měřítka automaticky + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Které anotativní objekty převezmou nově nastavené měřítko anotace (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Jednotky délky, úhlu a vložení + .drawing-units-2 = Jednotky výkresu… + .drafting = Kreslení + .drafting-rotation = Otočení kreslení + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Otáčí nitkový kříž a rastr uchopení v aktivním USS (SNAPANG). + .polar-tracking-increment = Krok polárního sledování + .snap-and-grid = Uchopení a rastr + .snap-modes-grid-spacing-and-object-snap = Režimy uchopení, rozteč rastru a uchopení objektů + .drafting-settings-2 = Nastavení kreslení… + .modeling-3d = 3D modelování + .display-tools = Nástroje zobrazení + .show-the-navigation-cube-navvcube = Zobrazit navigační kostku (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Zobrazit ikonu USS (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Umístit ikonu USS do počátku (UCSICON ORigin) + .display-resolution = Rozlišení zobrazení + .applies-to-the-current-drawing = platí pro aktuální výkres + .isolines-per-surface = Izočáry na plochu + .show-silhouette-edges-on-solids-dispsilh = Zobrazit obrysové hrany těles (DISPSILH) + .surface-density-u = Hustota plochy U + .surface-density-v = Hustota plochy V + .surface-type = Typ plochy + .record-the-history-of-composite-solids-solidhist = Zaznamenávat historii složených těles (SOLIDHIST) + .show-solid-history = Zobrazit historii těles + .not-available = Není k dispozici + .open-folder = Otevřít složku + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Kde aplikace uchovává vlastní soubory. Tato umístění jsou pevná. + .configuration = Konfigurace + .plot-styles = Styly vykreslení + .autosave-files = Soubory automatického ukládání .selection-modes = Režimy výběru .use-shift-to-add-to-selection-pickadd = Použít Shift pro přidání do výběru (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Tažení kreslí obdélník místo lasa (PICKDRAG) diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 42781eee3..6560a0558 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1376,6 +1376,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Steuert sowohl das sichtbare Auswahlkästchen als auch den Klickbereich. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Anwendungen + .installed-plugins-and-their-sources = Installierte Plug-ins und ihre Quellen + .plugins-2 = Plug-ins… + .keyboard-shortcuts-2 = Tastenkürzel + .keyboard-shortcuts-3 = Tastenkürzel… + .command-aliases-2 = Befehlsaliase + .command-aliases-3 = Befehlsaliase… + .file-safety = Dateisicherheit + .automatic-save = Automatisches Speichern + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minuten zwischen Wiederherstellungsspeicherungen in eine .sv$-Datei; 0 schaltet es aus (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Beim Überschreiben eine .bak-Kopie behalten (ISAVEBAK) + .plotting = Plotten + .plot-device-paper-scale-and-plot-styles = Plotgerät, Papier, Maßstab und Plotstile + .plot-and-page-setup = Plotten und Seiteneinrichtung… + .fill-truetype-glyphs-textfill = TrueType-Glyphen füllen (TEXTFILL) + .command-line = Befehlszeile + .prompt-lines = Eingabeaufforderungszeilen + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Temporäre Eingabeaufforderungszeilen über dem Befehlsfenster (CLIPROMPTLINES). + .history-fade-time = Ausblendzeit des Verlaufs + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Wie lange Verlaufszeilen sichtbar bleiben; 0 überspringt sie (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Klick auf überlappende Objekte öffnet eine Auswahl + .block-edit = Blockbearbeitung + .double-click-to-edit-block-in-place-refedit = Doppelklick bearbeitet den Block an Ort und Stelle (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Doppelklick auf Blöcke mit Attributen bearbeitet diese (ATTEDIT) + .user-preferences = Benutzereinstellungen + .zoom = Zoom + .reverse-mouse-wheel-zoom-zoomwheel = Mausradzoom umkehren (ZOOMWHEEL) + .zoom-factor = Zoomfaktor + .how-far-one-wheel-notch-zooms-zoomfactor = Wie weit eine Radrastung zoomt (ZOOMFACTOR). + .text-and-dimensions = Text und Bemaßung + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT bearbeitet ein Objekt und endet (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Fortgesetzte Bemaßungen erben Layer und Stil der Basisbemaßung (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM-Ursprungspriorität + .which-points-qdim-measures-from-also-settable-inside-the-command = Von welchen Punkten QDIM misst. Auch im Befehl einstellbar. + .add-scales-automatically = Maßstäbe automatisch hinzufügen + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Welche annotativen Objekte einen neu gesetzten Beschriftungsmaßstab übernehmen (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Längen-, Winkel- und Einfügeeinheiten + .drawing-units-2 = Zeichnungseinheiten… + .drafting = Entwurf + .drafting-rotation = Entwurfsdrehung + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Dreht Fadenkreuz und Fangraster im aktiven BKS (SNAPANG). + .polar-tracking-increment = Polarer Verfolgungsschritt + .snap-and-grid = Fang und Raster + .snap-modes-grid-spacing-and-object-snap = Fangmodi, Rasterabstand und Objektfang + .drafting-settings-2 = Entwurfseinstellungen… + .modeling-3d = 3D-Modellierung + .display-tools = Anzeigewerkzeuge + .show-the-navigation-cube-navvcube = Navigationswürfel anzeigen (NAVVCUBE) + .show-the-ucs-icon-ucsicon = BKS-Symbol anzeigen (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = BKS-Symbol am Ursprung platzieren (UCSICON ORigin) + .display-resolution = Anzeigeauflösung + .applies-to-the-current-drawing = gilt für die aktuelle Zeichnung + .isolines-per-surface = Isolinien pro Fläche + .show-silhouette-edges-on-solids-dispsilh = Silhouettenkanten an Volumenkörpern anzeigen (DISPSILH) + .surface-density-u = Flächendichte U + .surface-density-v = Flächendichte V + .surface-type = Flächentyp + .record-the-history-of-composite-solids-solidhist = Verlauf zusammengesetzter Volumenkörper aufzeichnen (SOLIDHIST) + .show-solid-history = Volumenkörperverlauf anzeigen + .not-available = Nicht verfügbar + .open-folder = Ordner öffnen + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Wo die Anwendung ihre eigenen Dateien ablegt. Diese Orte sind fest. + .configuration = Konfiguration + .plot-styles = Plotstile + .autosave-files = Dateien für automatisches Speichern .selection-modes = Auswahlmodi .use-shift-to-add-to-selection-pickadd = Umschalt zum Hinzufügen zur Auswahl verwenden (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Ziehen mit gedrückter Maustaste zeichnet ein Rechteck statt eines Lassos (PICKDRAG) diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 96d170fd6..4adf40ce0 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1377,6 +1377,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Ελέγχει τόσο το ορατό πλαίσιο επιλογής όσο και την περιοχή κλικ. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Εφαρμογές + .installed-plugins-and-their-sources = Εγκατεστημένα πρόσθετα και οι πηγές τους + .plugins-2 = Πρόσθετα… + .keyboard-shortcuts-2 = Συντομεύσεις πληκτρολογίου + .keyboard-shortcuts-3 = Συντομεύσεις πληκτρολογίου… + .command-aliases-2 = Ψευδώνυμα εντολών + .command-aliases-3 = Ψευδώνυμα εντολών… + .file-safety = Ασφάλεια αρχείων + .automatic-save = Αυτόματη αποθήκευση + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Λεπτά μεταξύ αποθηκεύσεων ανάκτησης σε αρχείο .sv$· το 0 την απενεργοποιεί (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Διατήρηση αντιγράφου .bak κατά την αντικατάσταση σχεδίου (ISAVEBAK) + .plotting = Εκτύπωση + .plot-device-paper-scale-and-plot-styles = Συσκευή εκτύπωσης, χαρτί, κλίμακα και στυλ εκτύπωσης + .plot-and-page-setup = Εκτύπωση και διαμόρφωση σελίδας… + .fill-truetype-glyphs-textfill = Γέμισμα γραμματοσειρών TrueType (TEXTFILL) + .command-line = Γραμμή εντολών + .prompt-lines = Γραμμές προτροπής + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Προσωρινές γραμμές προτροπής πάνω από το παράθυρο εντολών (CLIPROMPTLINES). + .history-fade-time = Χρόνος εξασθένισης ιστορικού + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Πόσο μένουν ορατές οι γραμμές ιστορικού· το 0 τις παραλείπει (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Το κλικ σε επικαλυπτόμενα αντικείμενα ανοίγει επιλογέα + .block-edit = Επεξεργασία μπλοκ + .double-click-to-edit-block-in-place-refedit = Διπλό κλικ για επεξεργασία μπλοκ επί τόπου (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Διπλό κλικ σε μπλοκ με χαρακτηριστικά για επεξεργασία τους (ATTEDIT) + .user-preferences = Προτιμήσεις χρήστη + .zoom = Ζουμ + .reverse-mouse-wheel-zoom-zoomwheel = Αντιστροφή ζουμ με τον τροχό (ZOOMWHEEL) + .zoom-factor = Συντελεστής ζουμ + .how-far-one-wheel-notch-zooms-zoomfactor = Πόσο ζουμάρει ένα σκαλί του τροχού (ZOOMFACTOR). + .text-and-dimensions = Κείμενο και διαστάσεις + .textedit-edits-one-object-and-ends-texteditmode = Το TEXTEDIT επεξεργάζεται ένα αντικείμενο και τερματίζει (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Οι συνεχόμενες διαστάσεις κληρονομούν επίπεδο και στυλ από τη βασική (DIMCONTINUEMODE) + .qdim-origin-priority = Προτεραιότητα αρχής QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = Από ποια σημεία μετρά το QDIM. Ρυθμίζεται και μέσα στην εντολή. + .add-scales-automatically = Αυτόματη προσθήκη κλιμάκων + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Ποια επισημειωματικά αντικείμενα παίρνουν μια νέα κλίμακα επισημείωσης (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Μονάδες μήκους, γωνίας και εισαγωγής + .drawing-units-2 = Μονάδες σχεδίου… + .drafting = Σχεδίαση + .drafting-rotation = Περιστροφή σχεδίασης + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Περιστρέφει το σταυρόνημα και το πλέγμα έλξης στο ενεργό UCS (SNAPANG). + .polar-tracking-increment = Βήμα πολικής παρακολούθησης + .snap-and-grid = Έλξη και πλέγμα + .snap-modes-grid-spacing-and-object-snap = Λειτουργίες έλξης, βήμα πλέγματος και έλξη αντικειμένων + .drafting-settings-2 = Ρυθμίσεις σχεδίασης… + .modeling-3d = Μοντελοποίηση 3D + .display-tools = Εργαλεία προβολής + .show-the-navigation-cube-navvcube = Εμφάνιση του κύβου πλοήγησης (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Εμφάνιση του εικονιδίου UCS (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Τοποθέτηση του εικονιδίου UCS στην αρχή των αξόνων (UCSICON ORigin) + .display-resolution = Ανάλυση εμφάνισης + .applies-to-the-current-drawing = ισχύει για το τρέχον σχέδιο + .isolines-per-surface = Ισοϋψείς ανά επιφάνεια + .show-silhouette-edges-on-solids-dispsilh = Εμφάνιση ακμών περιγράμματος σε στερεά (DISPSILH) + .surface-density-u = Πυκνότητα επιφάνειας U + .surface-density-v = Πυκνότητα επιφάνειας V + .surface-type = Τύπος επιφάνειας + .record-the-history-of-composite-solids-solidhist = Καταγραφή ιστορικού σύνθετων στερεών (SOLIDHIST) + .show-solid-history = Εμφάνιση ιστορικού στερεών + .not-available = Μη διαθέσιμο + .open-folder = Άνοιγμα φακέλου + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Πού κρατά η εφαρμογή τα δικά της αρχεία. Οι θέσεις αυτές είναι σταθερές. + .configuration = Ρυθμίσεις + .plot-styles = Στυλ εκτύπωσης + .autosave-files = Αρχεία αυτόματης αποθήκευσης .selection-modes = Τρόποι επιλογής .use-shift-to-add-to-selection-pickadd = Χρήση Shift για προσθήκη στην επιλογή (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Το πάτημα-και-σύρσιμο σχεδιάζει ορθογώνιο αντί για λάσο (PICKDRAG) diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 0a4d99304..01541f940 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1377,6 +1377,71 @@ common = .pick-box-size = Pick box size .controls-both-the-visible-selection-box-and-the-click-aperture = Controls both the visible selection box and the click aperture. .visual-effect-settings = Visual Effect Settings + .applications = Applications + .installed-plugins-and-their-sources = Installed plugins and their sources + .plugins-2 = Plugins… + .keyboard-shortcuts-2 = Keyboard shortcuts + .keyboard-shortcuts-3 = Keyboard Shortcuts… + .command-aliases-2 = Command aliases + .command-aliases-3 = Command Aliases… + .file-safety = File Safety + .automatic-save = Automatic save + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minutes between recovery saves to a .sv$ file; 0 turns it off (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Keep a .bak copy when overwriting a drawing (ISAVEBAK) + .plotting = Plotting + .plot-device-paper-scale-and-plot-styles = Plot device, paper, scale and plot styles + .plot-and-page-setup = Plot and Page Setup… + .fill-truetype-glyphs-textfill = Fill TrueType glyphs (TEXTFILL) + .command-line = Command Line + .prompt-lines = Prompt lines + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Temporary prompt lines shown above the command window (CLIPROMPTLINES). + .history-fade-time = History fade time + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = How long overlay history lines stay visible; 0 skips them (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Clicking overlapping objects opens a picker + .block-edit = Block Edit + .double-click-to-edit-block-in-place-refedit = Double-click to edit block in-place (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Double-click attributed blocks to edit attributes (ATTEDIT) + .user-preferences = User Preferences + .zoom = Zoom + .reverse-mouse-wheel-zoom-zoomwheel = Reverse mouse wheel zoom (ZOOMWHEEL) + .zoom-factor = Zoom factor + .how-far-one-wheel-notch-zooms-zoomfactor = How far one wheel notch zooms (ZOOMFACTOR). + .text-and-dimensions = Text and Dimensions + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT edits one object and ends (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Continued dimensions inherit the base dimension's layer and style (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM origin priority + .which-points-qdim-measures-from-also-settable-inside-the-command = Which points QDIM measures from. Also settable inside the command. + .add-scales-automatically = Add scales automatically + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Which annotative objects pick up a newly set annotation scale (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Length, angle and insertion units + .drawing-units-2 = Drawing Units… + .drafting = Drafting + .drafting-rotation = Drafting rotation + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Rotates the crosshair and the snap grid in the active UCS (SNAPANG). + .polar-tracking-increment = Polar tracking increment + .snap-and-grid = Snap and Grid + .snap-modes-grid-spacing-and-object-snap = Snap modes, grid spacing and object snap + .drafting-settings-2 = Drafting Settings… + .modeling-3d = 3D Modeling + .display-tools = Display Tools + .show-the-navigation-cube-navvcube = Show the navigation cube (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Show the UCS icon (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Place the UCS icon at the origin (UCSICON ORigin) + .display-resolution = Display Resolution + .applies-to-the-current-drawing = applies to the current drawing + .isolines-per-surface = Isolines per surface + .show-silhouette-edges-on-solids-dispsilh = Show silhouette edges on solids (DISPSILH) + .surface-density-u = Surface density U + .surface-density-v = Surface density V + .surface-type = Surface type + .record-the-history-of-composite-solids-solidhist = Record the history of composite solids (SOLIDHIST) + .show-solid-history = Show solid history + .not-available = Not available + .open-folder = Open folder + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Where the application keeps its own files. These locations are fixed. + .configuration = Configuration + .plot-styles = Plot styles + .autosave-files = Autosave files .selection-modes = Selection Modes .use-shift-to-add-to-selection-pickadd = Use Shift to add to selection (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Press and drag draws a rectangle instead of a lasso (PICKDRAG) diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 6941fa1f2..c53101c47 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1376,6 +1376,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Controla tanto el cuadro de selección visible como el área de clic. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Aplicaciones + .installed-plugins-and-their-sources = Complementos instalados y sus orígenes + .plugins-2 = Complementos… + .keyboard-shortcuts-2 = Atajos de teclado + .keyboard-shortcuts-3 = Atajos de teclado… + .command-aliases-2 = Alias de comandos + .command-aliases-3 = Alias de comandos… + .file-safety = Seguridad de archivos + .automatic-save = Guardado automático + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minutos entre guardados de recuperación en un archivo .sv$; 0 lo desactiva (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Conservar una copia .bak al sobrescribir un dibujo (ISAVEBAK) + .plotting = Trazado + .plot-device-paper-scale-and-plot-styles = Dispositivo de trazado, papel, escala y estilos de trazado + .plot-and-page-setup = Trazar y configurar página… + .fill-truetype-glyphs-textfill = Rellenar glifos TrueType (TEXTFILL) + .command-line = Línea de comandos + .prompt-lines = Líneas de solicitud + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Líneas de solicitud temporales sobre la ventana de comandos (CLIPROMPTLINES). + .history-fade-time = Tiempo de desvanecimiento del historial + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Cuánto permanecen visibles las líneas del historial; 0 las omite (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Al pulsar sobre objetos superpuestos se abre un selector + .block-edit = Edición de bloque + .double-click-to-edit-block-in-place-refedit = Doble clic para editar el bloque in situ (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Doble clic en bloques con atributos para editarlos (ATTEDIT) + .user-preferences = Preferencias de usuario + .zoom = Zoom + .reverse-mouse-wheel-zoom-zoomwheel = Invertir el zoom de la rueda del ratón (ZOOMWHEEL) + .zoom-factor = Factor de zoom + .how-far-one-wheel-notch-zooms-zoomfactor = Cuánto amplía un paso de la rueda (ZOOMFACTOR). + .text-and-dimensions = Texto y acotación + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT edita un objeto y termina (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Las cotas continuadas heredan la capa y el estilo de la cota base (DIMCONTINUEMODE) + .qdim-origin-priority = Prioridad de origen de QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = Desde qué puntos mide QDIM. También se ajusta dentro del comando. + .add-scales-automatically = Añadir escalas automáticamente + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Qué objetos anotativos adoptan una escala de anotación recién definida (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Unidades de longitud, ángulo e inserción + .drawing-units-2 = Unidades del dibujo… + .drafting = Dibujo + .drafting-rotation = Rotación de dibujo + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Gira la mira y la rejilla de forzado en el SCP activo (SNAPANG). + .polar-tracking-increment = Incremento de rastreo polar + .snap-and-grid = Forzado y rejilla + .snap-modes-grid-spacing-and-object-snap = Modos de forzado, espaciado de rejilla y referencia a objetos + .drafting-settings-2 = Parámetros de dibujo… + .modeling-3d = Modelado 3D + .display-tools = Herramientas de visualización + .show-the-navigation-cube-navvcube = Mostrar el cubo de navegación (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Mostrar el icono SCP (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Situar el icono SCP en el origen (UCSICON ORigin) + .display-resolution = Resolución de visualización + .applies-to-the-current-drawing = se aplica al dibujo actual + .isolines-per-surface = Isolíneas por superficie + .show-silhouette-edges-on-solids-dispsilh = Mostrar aristas de silueta en sólidos (DISPSILH) + .surface-density-u = Densidad de superficie U + .surface-density-v = Densidad de superficie V + .surface-type = Tipo de superficie + .record-the-history-of-composite-solids-solidhist = Registrar el historial de sólidos compuestos (SOLIDHIST) + .show-solid-history = Mostrar el historial de sólidos + .not-available = No disponible + .open-folder = Abrir carpeta + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Dónde guarda la aplicación sus propios archivos. Estas ubicaciones son fijas. + .configuration = Configuración + .plot-styles = Estilos de trazado + .autosave-files = Archivos de guardado automático .selection-modes = Modos de selección .use-shift-to-add-to-selection-pickadd = Usar Mayús para añadir a la selección (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Arrastrar dibuja un rectángulo en lugar de un lazo (PICKDRAG) diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index f251e2663..cc2df4dd5 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1375,6 +1375,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Säätää sekä näkyvää valintaruutua että napsautusaluetta. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Sovellukset + .installed-plugins-and-their-sources = Asennetut laajennukset ja niiden lähteet + .plugins-2 = Laajennukset… + .keyboard-shortcuts-2 = Pikanäppäimet + .keyboard-shortcuts-3 = Pikanäppäimet… + .command-aliases-2 = Komentojen aliakset + .command-aliases-3 = Komentojen aliakset… + .file-safety = Tiedostoturvallisuus + .automatic-save = Automaattinen tallennus + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minuutit palautustallennusten välillä .sv$-tiedostoon; 0 poistaa käytöstä (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Säilytä .bak-kopio piirustusta korvattaessa (ISAVEBAK) + .plotting = Tulostus + .plot-device-paper-scale-and-plot-styles = Tulostuslaite, paperi, mittakaava ja tulostustyylit + .plot-and-page-setup = Tulosta ja sivun asetukset… + .fill-truetype-glyphs-textfill = Täytä TrueType-merkit (TEXTFILL) + .command-line = Komentorivi + .prompt-lines = Kehoterivit + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Väliaikaiset kehoterivit komentoikkunan yläpuolella (CLIPROMPTLINES). + .history-fade-time = Historian häivytysaika + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Kuinka kauan historiarivit pysyvät näkyvissä; 0 ohittaa ne (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Päällekkäisten objektien napsauttaminen avaa valitsimen + .block-edit = Lohkon muokkaus + .double-click-to-edit-block-in-place-refedit = Kaksoisnapsautus muokkaa lohkoa paikallaan (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Kaksoisnapsautus määritteellisiin lohkoihin muokkaa määritteitä (ATTEDIT) + .user-preferences = Käyttäjäasetukset + .zoom = Zoomaus + .reverse-mouse-wheel-zoom-zoomwheel = Käännä hiiren rullan zoomaus (ZOOMWHEEL) + .zoom-factor = Zoomauskerroin + .how-far-one-wheel-notch-zooms-zoomfactor = Kuinka paljon yksi rullan pykälä zoomaa (ZOOMFACTOR). + .text-and-dimensions = Teksti ja mitoitus + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT muokkaa yhden objektin ja päättyy (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Jatketut mitat perivät perusmitan tason ja tyylin (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM-lähtöpisteen prioriteetti + .which-points-qdim-measures-from-also-settable-inside-the-command = Mistä pisteistä QDIM mittaa. Asetettavissa myös komennon sisällä. + .add-scales-automatically = Lisää mittakaavat automaattisesti + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Mitkä selittävät objektit omaksuvat juuri asetetun selitemittakaavan (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Pituus-, kulma- ja lisäysyksiköt + .drawing-units-2 = Piirustuksen yksiköt… + .drafting = Piirto + .drafting-rotation = Piirron kierto + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Kiertää hiusristikkoa ja tarttumaruudukkoa aktiivisessa UCS:ssä (SNAPANG). + .polar-tracking-increment = Polaarisen seurannan askel + .snap-and-grid = Tartunta ja ruudukko + .snap-modes-grid-spacing-and-object-snap = Tarttumatilat, ruudukkoväli ja objektitartunta + .drafting-settings-2 = Piirtoasetukset… + .modeling-3d = 3D-mallinnus + .display-tools = Näyttötyökalut + .show-the-navigation-cube-navvcube = Näytä navigointikuutio (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Näytä UCS-kuvake (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Sijoita UCS-kuvake origoon (UCSICON ORigin) + .display-resolution = Näytön tarkkuus + .applies-to-the-current-drawing = koskee nykyistä piirustusta + .isolines-per-surface = Isoviivoja pintaa kohti + .show-silhouette-edges-on-solids-dispsilh = Näytä siluettireunat kappaleissa (DISPSILH) + .surface-density-u = Pinnan tiheys U + .surface-density-v = Pinnan tiheys V + .surface-type = Pinnan tyyppi + .record-the-history-of-composite-solids-solidhist = Tallenna koostekappaleiden historia (SOLIDHIST) + .show-solid-history = Näytä kappalehistoria + .not-available = Ei käytettävissä + .open-folder = Avaa kansio + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Missä sovellus säilyttää omat tiedostonsa. Nämä sijainnit ovat kiinteät. + .configuration = Määritykset + .plot-styles = Tulostustyylit + .autosave-files = Automaattitallennuksen tiedostot .selection-modes = Valintatilat .use-shift-to-add-to-selection-pickadd = Käytä Vaihto-näppäintä valintaan lisäämiseen (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Painallus ja veto piirtää suorakulmion lasson sijaan (PICKDRAG) diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index b04978ac3..34b31d280 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1376,6 +1376,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Contrôle à la fois la case de sélection visible et la zone de clic. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Applications + .installed-plugins-and-their-sources = Plug-ins installés et leurs sources + .plugins-2 = Plug-ins… + .keyboard-shortcuts-2 = Raccourcis clavier + .keyboard-shortcuts-3 = Raccourcis clavier… + .command-aliases-2 = Alias de commandes + .command-aliases-3 = Alias de commandes… + .file-safety = Sécurité des fichiers + .automatic-save = Enregistrement automatique + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minutes entre les enregistrements de récupération dans un fichier .sv$ ; 0 le désactive (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Conserver une copie .bak lors de l'écrasement d'un dessin (ISAVEBAK) + .plotting = Traçage + .plot-device-paper-scale-and-plot-styles = Périphérique de traçage, papier, échelle et styles de tracé + .plot-and-page-setup = Tracer et mise en page… + .fill-truetype-glyphs-textfill = Remplir les glyphes TrueType (TEXTFILL) + .command-line = Ligne de commande + .prompt-lines = Lignes d'invite + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Lignes d'invite temporaires au-dessus de la fenêtre de commande (CLIPROMPTLINES). + .history-fade-time = Durée d'estompage de l'historique + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Combien de temps les lignes d'historique restent visibles ; 0 les ignore (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Cliquer sur des objets superposés ouvre un sélecteur + .block-edit = Édition de bloc + .double-click-to-edit-block-in-place-refedit = Double-clic pour modifier le bloc sur place (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Double-clic sur les blocs avec attributs pour les modifier (ATTEDIT) + .user-preferences = Préférences utilisateur + .zoom = Zoom + .reverse-mouse-wheel-zoom-zoomwheel = Inverser le zoom à la molette (ZOOMWHEEL) + .zoom-factor = Facteur de zoom + .how-far-one-wheel-notch-zooms-zoomfactor = De combien zoome un cran de molette (ZOOMFACTOR). + .text-and-dimensions = Texte et cotation + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT modifie un objet puis se termine (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Les cotes continues héritent du calque et du style de la cote de base (DIMCONTINUEMODE) + .qdim-origin-priority = Priorité d'origine QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = Depuis quels points QDIM mesure. Réglable aussi dans la commande. + .add-scales-automatically = Ajouter les échelles automatiquement + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Quels objets annotatifs adoptent une nouvelle échelle d'annotation (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Unités de longueur, d'angle et d'insertion + .drawing-units-2 = Unités du dessin… + .drafting = Dessin + .drafting-rotation = Rotation du dessin + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Fait pivoter le réticule et la grille d'accrochage dans le SCU actif (SNAPANG). + .polar-tracking-increment = Incrément de repérage polaire + .snap-and-grid = Accrochage et grille + .snap-modes-grid-spacing-and-object-snap = Modes d'accrochage, pas de la grille et accrochage aux objets + .drafting-settings-2 = Paramètres de dessin… + .modeling-3d = Modélisation 3D + .display-tools = Outils d'affichage + .show-the-navigation-cube-navvcube = Afficher le cube de navigation (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Afficher l'icône SCU (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Placer l'icône SCU à l'origine (UCSICON ORigin) + .display-resolution = Résolution d'affichage + .applies-to-the-current-drawing = s'applique au dessin en cours + .isolines-per-surface = Isolignes par surface + .show-silhouette-edges-on-solids-dispsilh = Afficher les arêtes de silhouette sur les solides (DISPSILH) + .surface-density-u = Densité de surface U + .surface-density-v = Densité de surface V + .surface-type = Type de surface + .record-the-history-of-composite-solids-solidhist = Enregistrer l'historique des solides composés (SOLIDHIST) + .show-solid-history = Afficher l'historique des solides + .not-available = Non disponible + .open-folder = Ouvrir le dossier + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Où l'application range ses propres fichiers. Ces emplacements sont fixes. + .configuration = Configuration + .plot-styles = Styles de tracé + .autosave-files = Fichiers d'enregistrement automatique .selection-modes = Modes de sélection .use-shift-to-add-to-selection-pickadd = Utiliser Maj pour ajouter à la sélection (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Le cliquer-glisser trace un rectangle au lieu d'un lasso (PICKDRAG) diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index a2a6224b3..6f6f276e0 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1364,6 +1364,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = दिखने वाले चयन बॉक्स और क्लिक क्षेत्र दोनों को नियंत्रित करता है। .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = अनुप्रयोग + .installed-plugins-and-their-sources = स्थापित प्लगइन और उनके स्रोत + .plugins-2 = प्लगइन… + .keyboard-shortcuts-2 = कीबोर्ड शॉर्टकट + .keyboard-shortcuts-3 = कीबोर्ड शॉर्टकट… + .command-aliases-2 = कमांड उपनाम + .command-aliases-3 = कमांड उपनाम… + .file-safety = फ़ाइल सुरक्षा + .automatic-save = स्वतः सहेजना + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = .sv$ फ़ाइल में पुनर्प्राप्ति सहेजने के बीच मिनट; 0 इसे बंद करता है (SAVETIME)। + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = ड्रॉइंग को अधिलेखित करते समय .bak प्रति रखें (ISAVEBAK) + .plotting = प्लॉटिंग + .plot-device-paper-scale-and-plot-styles = प्लॉट डिवाइस, कागज़, स्केल और प्लॉट शैलियाँ + .plot-and-page-setup = प्लॉट और पेज सेटअप… + .fill-truetype-glyphs-textfill = TrueType ग्लिफ़ भरें (TEXTFILL) + .command-line = कमांड लाइन + .prompt-lines = प्रॉम्प्ट पंक्तियाँ + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = कमांड विंडो के ऊपर अस्थायी प्रॉम्प्ट पंक्तियाँ (CLIPROMPTLINES)। + .history-fade-time = इतिहास फ़ेड समय + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = इतिहास पंक्तियाँ कितनी देर दिखती हैं; 0 उन्हें छोड़ देता है (COMMANDLINEFADETIME)। + .clicking-overlapping-objects-opens-a-picker = अतिव्यापी ऑब्जेक्ट पर क्लिक करने से चयनकर्ता खुलता है + .block-edit = ब्लॉक संपादन + .double-click-to-edit-block-in-place-refedit = ब्लॉक को यथास्थान संपादित करने के लिए डबल-क्लिक करें (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = विशेषता वाले ब्लॉक पर डबल-क्लिक कर विशेषताएँ संपादित करें (ATTEDIT) + .user-preferences = उपयोगकर्ता प्राथमिकताएँ + .zoom = ज़ूम + .reverse-mouse-wheel-zoom-zoomwheel = माउस व्हील ज़ूम उलटें (ZOOMWHEEL) + .zoom-factor = ज़ूम गुणक + .how-far-one-wheel-notch-zooms-zoomfactor = व्हील का एक पायदान कितना ज़ूम करता है (ZOOMFACTOR)। + .text-and-dimensions = पाठ और आयाम + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT एक ऑब्जेक्ट संपादित कर समाप्त होता है (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = निरंतर आयाम आधार आयाम की लेयर और शैली प्राप्त करते हैं (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM मूल प्राथमिकता + .which-points-qdim-measures-from-also-settable-inside-the-command = QDIM किन बिंदुओं से मापता है। कमांड के भीतर भी सेट किया जा सकता है। + .add-scales-automatically = स्केल स्वतः जोड़ें + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = कौन-से एनोटेटिव ऑब्जेक्ट नया एनोटेशन स्केल लेते हैं (ANNOAUTOSCALE)। + .length-angle-and-insertion-units = लंबाई, कोण और सम्मिलन इकाइयाँ + .drawing-units-2 = ड्रॉइंग इकाइयाँ… + .drafting = ड्राफ्टिंग + .drafting-rotation = ड्राफ्टिंग घूर्णन + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = सक्रिय UCS में क्रॉसहेयर और स्नैप ग्रिड घुमाता है (SNAPANG)। + .polar-tracking-increment = ध्रुवीय ट्रैकिंग वृद्धि + .snap-and-grid = स्नैप और ग्रिड + .snap-modes-grid-spacing-and-object-snap = स्नैप मोड, ग्रिड अंतराल और ऑब्जेक्ट स्नैप + .drafting-settings-2 = ड्राफ्टिंग सेटिंग्स… + .modeling-3d = 3D मॉडलिंग + .display-tools = प्रदर्शन उपकरण + .show-the-navigation-cube-navvcube = नेविगेशन क्यूब दिखाएँ (NAVVCUBE) + .show-the-ucs-icon-ucsicon = UCS आइकन दिखाएँ (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = UCS आइकन मूल बिंदु पर रखें (UCSICON ORigin) + .display-resolution = प्रदर्शन विभेदन + .applies-to-the-current-drawing = वर्तमान ड्रॉइंग पर लागू + .isolines-per-surface = प्रति सतह आइसोलाइन + .show-silhouette-edges-on-solids-dispsilh = ठोसों पर सिल्हूट किनारे दिखाएँ (DISPSILH) + .surface-density-u = सतह घनत्व U + .surface-density-v = सतह घनत्व V + .surface-type = सतह प्रकार + .record-the-history-of-composite-solids-solidhist = संयुक्त ठोसों का इतिहास रिकॉर्ड करें (SOLIDHIST) + .show-solid-history = ठोस इतिहास दिखाएँ + .not-available = उपलब्ध नहीं + .open-folder = फ़ोल्डर खोलें + .where-the-application-keeps-its-own-files-these-locations-are-fixed = अनुप्रयोग अपनी फ़ाइलें कहाँ रखता है। ये स्थान निश्चित हैं। + .configuration = विन्यास + .plot-styles = प्लॉट शैलियाँ + .autosave-files = स्वतः सहेजी फ़ाइलें .selection-modes = चयन मोड .use-shift-to-add-to-selection-pickadd = चयन में जोड़ने के लिए Shift का उपयोग करें (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = दबाकर खींचने पर लैस्सो के बजाय आयत बनता है (PICKDRAG) diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 373ac32e8..9f8e66a81 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1375,6 +1375,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = A látható kijelölőnégyzetet és a kattintási területet is szabályozza. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Alkalmazások + .installed-plugins-and-their-sources = Telepített bővítmények és forrásaik + .plugins-2 = Bővítmények… + .keyboard-shortcuts-2 = Billentyűparancsok + .keyboard-shortcuts-3 = Billentyűparancsok… + .command-aliases-2 = Parancs-álnevek + .command-aliases-3 = Parancs-álnevek… + .file-safety = Fájlbiztonság + .automatic-save = Automatikus mentés + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Percek a .sv$ helyreállítási mentések között; a 0 kikapcsolja (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Rajz felülírásakor .bak másolat megőrzése (ISAVEBAK) + .plotting = Nyomtatás + .plot-device-paper-scale-and-plot-styles = Nyomtatóeszköz, papír, méretarány és nyomtatási stílusok + .plot-and-page-setup = Nyomtatás és oldalbeállítás… + .fill-truetype-glyphs-textfill = TrueType karakterek kitöltése (TEXTFILL) + .command-line = Parancssor + .prompt-lines = Kérdéssorok + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Ideiglenes kérdéssorok a parancsablak fölött (CLIPROMPTLINES). + .history-fade-time = Előzmények elhalványulási ideje + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Meddig maradnak láthatók az előzménysorok; a 0 kihagyja őket (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Átfedő objektumokra kattintva választó nyílik + .block-edit = Blokkszerkesztés + .double-click-to-edit-block-in-place-refedit = Dupla kattintás a blokk helyben szerkesztéséhez (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Dupla kattintás az attribútumos blokkok attribútumainak szerkesztéséhez (ATTEDIT) + .user-preferences = Felhasználói beállítások + .zoom = Nagyítás + .reverse-mouse-wheel-zoom-zoomwheel = Egérgörgős nagyítás megfordítása (ZOOMWHEEL) + .zoom-factor = Nagyítási tényező + .how-far-one-wheel-notch-zooms-zoomfactor = Mennyit nagyít egy görgőkattanás (ZOOMFACTOR). + .text-and-dimensions = Szöveg és méretezés + .textedit-edits-one-object-and-ends-texteditmode = A TEXTEDIT egy objektumot szerkeszt, majd befejezi (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = A folytatott méretek öröklik az alapméret fóliáját és stílusát (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM kiindulási prioritás + .which-points-qdim-measures-from-also-settable-inside-the-command = Mely pontoktól mér a QDIM. A parancson belül is beállítható. + .add-scales-automatically = Méretarányok automatikus hozzáadása + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Mely annotatív objektumok veszik át az újonnan beállított feliratozási méretarányt (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Hossz-, szög- és beszúrási egységek + .drawing-units-2 = Rajzegységek… + .drafting = Rajzolás + .drafting-rotation = Rajzolási elforgatás + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Elforgatja a szálkeresztet és az illesztőrácsot az aktív FKR-ben (SNAPANG). + .polar-tracking-increment = Poláris követési lépés + .snap-and-grid = Illesztés és rács + .snap-modes-grid-spacing-and-object-snap = Illesztési módok, rácstávolság és tárgyraszter + .drafting-settings-2 = Rajzolási beállítások… + .modeling-3d = 3D modellezés + .display-tools = Megjelenítési eszközök + .show-the-navigation-cube-navvcube = Navigációs kocka megjelenítése (NAVVCUBE) + .show-the-ucs-icon-ucsicon = FKR ikon megjelenítése (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = FKR ikon elhelyezése az origóban (UCSICON ORigin) + .display-resolution = Megjelenítési felbontás + .applies-to-the-current-drawing = az aktuális rajzra vonatkozik + .isolines-per-surface = Izovonalak felületenként + .show-silhouette-edges-on-solids-dispsilh = Sziluettélek megjelenítése testeken (DISPSILH) + .surface-density-u = Felületsűrűség U + .surface-density-v = Felületsűrűség V + .surface-type = Felülettípus + .record-the-history-of-composite-solids-solidhist = Összetett testek előzményeinek rögzítése (SOLIDHIST) + .show-solid-history = Testelőzmények megjelenítése + .not-available = Nem érhető el + .open-folder = Mappa megnyitása + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Hol tárolja az alkalmazás a saját fájljait. Ezek a helyek rögzítettek. + .configuration = Konfiguráció + .plot-styles = Nyomtatási stílusok + .autosave-files = Automatikus mentés fájljai .selection-modes = Kijelölési módok .use-shift-to-add-to-selection-pickadd = Shift használata a kijelöléshez adáshoz (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = A lenyomva húzás téglalapot rajzol lasszó helyett (PICKDRAG) diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index f2fb32860..264c68dc2 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1375,6 +1375,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Controlla sia il riquadro di selezione visibile sia l’area di clic. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Applicazioni + .installed-plugins-and-their-sources = Plug-in installati e relative origini + .plugins-2 = Plug-in… + .keyboard-shortcuts-2 = Scorciatoie da tastiera + .keyboard-shortcuts-3 = Scorciatoie da tastiera… + .command-aliases-2 = Alias dei comandi + .command-aliases-3 = Alias dei comandi… + .file-safety = Sicurezza dei file + .automatic-save = Salvataggio automatico + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minuti tra i salvataggi di ripristino in un file .sv$; 0 lo disattiva (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Conserva una copia .bak quando si sovrascrive un disegno (ISAVEBAK) + .plotting = Stampa + .plot-device-paper-scale-and-plot-styles = Dispositivo di stampa, foglio, scala e stili di stampa + .plot-and-page-setup = Stampa e imposta pagina… + .fill-truetype-glyphs-textfill = Riempi i glifi TrueType (TEXTFILL) + .command-line = Riga di comando + .prompt-lines = Righe di richiesta + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Righe di richiesta temporanee sopra la finestra dei comandi (CLIPROMPTLINES). + .history-fade-time = Tempo di dissolvenza della cronologia + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Per quanto restano visibili le righe di cronologia; 0 le salta (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Fare clic su oggetti sovrapposti apre un selettore + .block-edit = Modifica blocco + .double-click-to-edit-block-in-place-refedit = Doppio clic per modificare il blocco sul posto (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Doppio clic sui blocchi con attributi per modificarli (ATTEDIT) + .user-preferences = Preferenze utente + .zoom = Zoom + .reverse-mouse-wheel-zoom-zoomwheel = Inverti lo zoom con la rotellina (ZOOMWHEEL) + .zoom-factor = Fattore di zoom + .how-far-one-wheel-notch-zooms-zoomfactor = Di quanto ingrandisce uno scatto della rotellina (ZOOMFACTOR). + .text-and-dimensions = Testo e quote + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT modifica un oggetto e termina (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Le quote continue ereditano layer e stile dalla quota di base (DIMCONTINUEMODE) + .qdim-origin-priority = Priorità di origine QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = Da quali punti misura QDIM. Impostabile anche dentro il comando. + .add-scales-automatically = Aggiungi scale automaticamente + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Quali oggetti annotativi adottano una scala di annotazione appena impostata (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Unità di lunghezza, angolo e inserimento + .drawing-units-2 = Unità del disegno… + .drafting = Disegno + .drafting-rotation = Rotazione di disegno + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Ruota il mirino e la griglia di snap nell'UCS attivo (SNAPANG). + .polar-tracking-increment = Incremento di puntamento polare + .snap-and-grid = Snap e griglia + .snap-modes-grid-spacing-and-object-snap = Modalità di snap, passo della griglia e snap agli oggetti + .drafting-settings-2 = Impostazioni disegno… + .modeling-3d = Modellazione 3D + .display-tools = Strumenti di visualizzazione + .show-the-navigation-cube-navvcube = Mostra il cubo di navigazione (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Mostra l'icona UCS (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Colloca l'icona UCS nell'origine (UCSICON ORigin) + .display-resolution = Risoluzione di visualizzazione + .applies-to-the-current-drawing = si applica al disegno corrente + .isolines-per-surface = Isolinee per superficie + .show-silhouette-edges-on-solids-dispsilh = Mostra i bordi di silhouette sui solidi (DISPSILH) + .surface-density-u = Densità superficie U + .surface-density-v = Densità superficie V + .surface-type = Tipo di superficie + .record-the-history-of-composite-solids-solidhist = Registra la cronologia dei solidi composti (SOLIDHIST) + .show-solid-history = Mostra la cronologia dei solidi + .not-available = Non disponibile + .open-folder = Apri cartella + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Dove l'applicazione tiene i propri file. Queste posizioni sono fisse. + .configuration = Configurazione + .plot-styles = Stili di stampa + .autosave-files = File di salvataggio automatico .selection-modes = Modalità di selezione .use-shift-to-add-to-selection-pickadd = Usa Maiusc per aggiungere alla selezione (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Premi e trascina disegna un rettangolo invece di un lazo (PICKDRAG) diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index d6d37b14a..a1e8ace50 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1366,6 +1366,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = 表示される選択ボックスとクリック範囲の両方を制御します。 .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = アプリケーション + .installed-plugins-and-their-sources = インストール済みプラグインとその提供元 + .plugins-2 = プラグイン… + .keyboard-shortcuts-2 = キーボード ショートカット + .keyboard-shortcuts-3 = キーボード ショートカット… + .command-aliases-2 = コマンド エイリアス + .command-aliases-3 = コマンド エイリアス… + .file-safety = ファイルの安全性 + .automatic-save = 自動保存 + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = .sv$ ファイルへの復旧保存の間隔 (分)。0 で無効 (SAVETIME)。 + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = 図面を上書きするとき .bak のコピーを残す (ISAVEBAK) + .plotting = 印刷 + .plot-device-paper-scale-and-plot-styles = 印刷デバイス、用紙、尺度、印刷スタイル + .plot-and-page-setup = 印刷とページ設定… + .fill-truetype-glyphs-textfill = TrueType グリフを塗りつぶす (TEXTFILL) + .command-line = コマンド ライン + .prompt-lines = プロンプト行 + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = コマンド ウィンドウの上に表示する一時プロンプト行 (CLIPROMPTLINES)。 + .history-fade-time = 履歴のフェード時間 + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = 履歴行が表示され続ける時間。0 で表示しない (COMMANDLINEFADETIME)。 + .clicking-overlapping-objects-opens-a-picker = 重なったオブジェクトをクリックすると選択リストが開く + .block-edit = ブロック編集 + .double-click-to-edit-block-in-place-refedit = ダブルクリックでブロックをその場で編集 (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = 属性付きブロックをダブルクリックして属性を編集 (ATTEDIT) + .user-preferences = ユーザー設定 + .zoom = ズーム + .reverse-mouse-wheel-zoom-zoomwheel = マウス ホイールのズーム方向を反転 (ZOOMWHEEL) + .zoom-factor = ズーム倍率 + .how-far-one-wheel-notch-zooms-zoomfactor = ホイール 1 ノッチのズーム量 (ZOOMFACTOR)。 + .text-and-dimensions = 文字と寸法 + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT は 1 つのオブジェクトを編集して終了 (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = 連続寸法は基準寸法の画層とスタイルを引き継ぐ (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM の起点優先 + .which-points-qdim-measures-from-also-settable-inside-the-command = QDIM が測定する点。コマンド内でも設定できます。 + .add-scales-automatically = 尺度を自動的に追加 + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = 新しく設定した注釈尺度を受け取る注釈オブジェクト (ANNOAUTOSCALE)。 + .length-angle-and-insertion-units = 長さ、角度、挿入の単位 + .drawing-units-2 = 図面単位… + .drafting = 作図 + .drafting-rotation = 作図の回転 + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = アクティブな UCS でクロスヘアとスナップ グリッドを回転します (SNAPANG)。 + .polar-tracking-increment = 極トラッキングの増分 + .snap-and-grid = スナップとグリッド + .snap-modes-grid-spacing-and-object-snap = スナップ モード、グリッド間隔、オブジェクト スナップ + .drafting-settings-2 = 作図設定… + .modeling-3d = 3D モデリング + .display-tools = 表示ツール + .show-the-navigation-cube-navvcube = ナビゲーション キューブを表示 (NAVVCUBE) + .show-the-ucs-icon-ucsicon = UCS アイコンを表示 (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = UCS アイコンを原点に配置 (UCSICON ORigin) + .display-resolution = 表示解像度 + .applies-to-the-current-drawing = 現在の図面に適用されます + .isolines-per-surface = 面あたりのアイソライン + .show-silhouette-edges-on-solids-dispsilh = ソリッドのシルエット エッジを表示 (DISPSILH) + .surface-density-u = サーフェス密度 U + .surface-density-v = サーフェス密度 V + .surface-type = サーフェス タイプ + .record-the-history-of-composite-solids-solidhist = 複合ソリッドの履歴を記録 (SOLIDHIST) + .show-solid-history = ソリッドの履歴を表示 + .not-available = 利用できません + .open-folder = フォルダーを開く + .where-the-application-keeps-its-own-files-these-locations-are-fixed = アプリケーションが自身のファイルを置く場所です。これらの場所は固定です。 + .configuration = 構成 + .plot-styles = 印刷スタイル + .autosave-files = 自動保存ファイル .selection-modes = 選択モード .use-shift-to-add-to-selection-pickadd = Shift キーで選択に追加する (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = ドラッグで投げ縄ではなく矩形を描く (PICKDRAG) diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index c4423adb6..2a1c41d76 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1375,6 +1375,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = 표시되는 선택 상자와 클릭 영역을 모두 제어합니다. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = 응용 프로그램 + .installed-plugins-and-their-sources = 설치된 플러그인 및 해당 소스 + .plugins-2 = 플러그인… + .keyboard-shortcuts-2 = 키보드 단축키 + .keyboard-shortcuts-3 = 키보드 단축키… + .command-aliases-2 = 명령 별칭 + .command-aliases-3 = 명령 별칭… + .file-safety = 파일 안전 + .automatic-save = 자동 저장 + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = .sv$ 파일로 복구 저장하는 간격(분). 0이면 해제 (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = 도면을 덮어쓸 때 .bak 사본 유지 (ISAVEBAK) + .plotting = 플롯 + .plot-device-paper-scale-and-plot-styles = 플롯 장치, 용지, 축척 및 플롯 스타일 + .plot-and-page-setup = 플롯 및 페이지 설정… + .fill-truetype-glyphs-textfill = TrueType 글리프 채우기 (TEXTFILL) + .command-line = 명령행 + .prompt-lines = 프롬프트 행 + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = 명령 창 위에 표시할 임시 프롬프트 행 (CLIPROMPTLINES). + .history-fade-time = 히스토리 페이드 시간 + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = 히스토리 행이 표시되는 시간. 0이면 건너뜀 (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = 겹친 객체를 클릭하면 선택기가 열림 + .block-edit = 블록 편집 + .double-click-to-edit-block-in-place-refedit = 두 번 클릭하여 블록을 제자리에서 편집 (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = 속성이 있는 블록을 두 번 클릭하여 속성 편집 (ATTEDIT) + .user-preferences = 사용자 환경설정 + .zoom = 줌 + .reverse-mouse-wheel-zoom-zoomwheel = 마우스 휠 줌 방향 반전 (ZOOMWHEEL) + .zoom-factor = 줌 배율 + .how-far-one-wheel-notch-zooms-zoomfactor = 휠 한 칸이 확대하는 정도 (ZOOMFACTOR). + .text-and-dimensions = 문자 및 치수 + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT는 객체 하나를 편집하고 종료 (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = 연속 치수가 기준 치수의 도면층과 스타일을 상속 (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM 원점 우선순위 + .which-points-qdim-measures-from-also-settable-inside-the-command = QDIM이 측정하는 점. 명령 안에서도 설정할 수 있습니다. + .add-scales-automatically = 축척 자동 추가 + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = 새로 설정한 주석 축척을 적용받을 주석 객체 (ANNOAUTOSCALE). + .length-angle-and-insertion-units = 길이, 각도 및 삽입 단위 + .drawing-units-2 = 도면 단위… + .drafting = 제도 + .drafting-rotation = 제도 회전 + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = 활성 UCS에서 십자선과 스냅 그리드를 회전합니다 (SNAPANG). + .polar-tracking-increment = 극좌표 추적 증분 + .snap-and-grid = 스냅 및 그리드 + .snap-modes-grid-spacing-and-object-snap = 스냅 모드, 그리드 간격 및 객체 스냅 + .drafting-settings-2 = 제도 설정… + .modeling-3d = 3D 모델링 + .display-tools = 표시 도구 + .show-the-navigation-cube-navvcube = 탐색 큐브 표시 (NAVVCUBE) + .show-the-ucs-icon-ucsicon = UCS 아이콘 표시 (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = UCS 아이콘을 원점에 배치 (UCSICON ORigin) + .display-resolution = 표시 해상도 + .applies-to-the-current-drawing = 현재 도면에 적용됩니다 + .isolines-per-surface = 곡면당 아이소라인 + .show-silhouette-edges-on-solids-dispsilh = 솔리드의 실루엣 모서리 표시 (DISPSILH) + .surface-density-u = 곡면 밀도 U + .surface-density-v = 곡면 밀도 V + .surface-type = 곡면 유형 + .record-the-history-of-composite-solids-solidhist = 복합 솔리드의 히스토리 기록 (SOLIDHIST) + .show-solid-history = 솔리드 히스토리 표시 + .not-available = 사용할 수 없음 + .open-folder = 폴더 열기 + .where-the-application-keeps-its-own-files-these-locations-are-fixed = 응용 프로그램이 자체 파일을 두는 위치입니다. 이 위치는 고정입니다. + .configuration = 구성 + .plot-styles = 플롯 스타일 + .autosave-files = 자동 저장 파일 .selection-modes = 선택 모드 .use-shift-to-add-to-selection-pickadd = Shift 키로 선택에 추가 (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = 누른 채 끌면 올가미 대신 사각형을 그림 (PICKDRAG) diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 1d9c99d55..9f7b7c1fe 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1374,6 +1374,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Bepaalt zowel het zichtbare selectievak als het klikgebied. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Toepassingen + .installed-plugins-and-their-sources = Geïnstalleerde plug-ins en hun bronnen + .plugins-2 = Plug-ins… + .keyboard-shortcuts-2 = Sneltoetsen + .keyboard-shortcuts-3 = Sneltoetsen… + .command-aliases-2 = Opdrachtaliassen + .command-aliases-3 = Opdrachtaliassen… + .file-safety = Bestandsveiligheid + .automatic-save = Automatisch opslaan + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minuten tussen herstelopslagen naar een .sv$-bestand; 0 schakelt het uit (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Een .bak-kopie bewaren bij het overschrijven van een tekening (ISAVEBAK) + .plotting = Plotten + .plot-device-paper-scale-and-plot-styles = Plotapparaat, papier, schaal en plotstijlen + .plot-and-page-setup = Plotten en pagina-instelling… + .fill-truetype-glyphs-textfill = TrueType-glyphs vullen (TEXTFILL) + .command-line = Opdrachtregel + .prompt-lines = Promptregels + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Tijdelijke promptregels boven het opdrachtvenster (CLIPROMPTLINES). + .history-fade-time = Vervaagtijd van geschiedenis + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Hoe lang geschiedenisregels zichtbaar blijven; 0 slaat ze over (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Klikken op overlappende objecten opent een kiezer + .block-edit = Blok bewerken + .double-click-to-edit-block-in-place-refedit = Dubbelklik om het blok ter plaatse te bewerken (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Dubbelklik op blokken met attributen om die te bewerken (ATTEDIT) + .user-preferences = Gebruikersvoorkeuren + .zoom = Zoomen + .reverse-mouse-wheel-zoom-zoomwheel = Muiswielzoom omkeren (ZOOMWHEEL) + .zoom-factor = Zoomfactor + .how-far-one-wheel-notch-zooms-zoomfactor = Hoeveel één wielklik zoomt (ZOOMFACTOR). + .text-and-dimensions = Tekst en maatvoering + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT bewerkt één object en stopt (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Doorlopende maten erven laag en stijl van de basismaat (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM-oorsprongprioriteit + .which-points-qdim-measures-from-also-settable-inside-the-command = Vanaf welke punten QDIM meet. Ook in te stellen binnen de opdracht. + .add-scales-automatically = Schalen automatisch toevoegen + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Welke annotatieve objecten een nieuw ingestelde annotatieschaal overnemen (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Lengte-, hoek- en invoegeenheden + .drawing-units-2 = Tekeneenheden… + .drafting = Tekenen + .drafting-rotation = Tekenrotatie + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Draait het draadkruis en het vangraster in het actieve BCS (SNAPANG). + .polar-tracking-increment = Polaire volgstap + .snap-and-grid = Vang en raster + .snap-modes-grid-spacing-and-object-snap = Vangmodi, rasterafstand en objectvang + .drafting-settings-2 = Tekeninstellingen… + .modeling-3d = 3D-modelleren + .display-tools = Weergavegereedschappen + .show-the-navigation-cube-navvcube = Navigatiekubus tonen (NAVVCUBE) + .show-the-ucs-icon-ucsicon = BCS-pictogram tonen (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = BCS-pictogram op de oorsprong plaatsen (UCSICON ORigin) + .display-resolution = Weergaveresolutie + .applies-to-the-current-drawing = geldt voor de huidige tekening + .isolines-per-surface = Isolijnen per oppervlak + .show-silhouette-edges-on-solids-dispsilh = Silhouetranden op vaste lichamen tonen (DISPSILH) + .surface-density-u = Oppervlakdichtheid U + .surface-density-v = Oppervlakdichtheid V + .surface-type = Oppervlaktype + .record-the-history-of-composite-solids-solidhist = Geschiedenis van samengestelde vaste lichamen vastleggen (SOLIDHIST) + .show-solid-history = Geschiedenis van vaste lichamen tonen + .not-available = Niet beschikbaar + .open-folder = Map openen + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Waar de toepassing haar eigen bestanden bewaart. Deze locaties liggen vast. + .configuration = Configuratie + .plot-styles = Plotstijlen + .autosave-files = Bestanden voor automatisch opslaan .selection-modes = Selectiemodi .use-shift-to-add-to-selection-pickadd = Shift gebruiken om aan de selectie toe te voegen (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Slepen tekent een rechthoek in plaats van een lasso (PICKDRAG) diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 13bad04bc..03d2eeddd 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1375,6 +1375,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Steruje widocznym polem wyboru oraz obszarem kliknięcia. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Aplikacje + .installed-plugins-and-their-sources = Zainstalowane wtyczki i ich źródła + .plugins-2 = Wtyczki… + .keyboard-shortcuts-2 = Skróty klawiszowe + .keyboard-shortcuts-3 = Skróty klawiszowe… + .command-aliases-2 = Aliasy poleceń + .command-aliases-3 = Aliasy poleceń… + .file-safety = Bezpieczeństwo plików + .automatic-save = Automatyczny zapis + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minuty między zapisami odzyskiwania do pliku .sv$; 0 wyłącza (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Zachowaj kopię .bak przy nadpisywaniu rysunku (ISAVEBAK) + .plotting = Kreślenie + .plot-device-paper-scale-and-plot-styles = Urządzenie kreślące, papier, skala i style wydruku + .plot-and-page-setup = Kreśl i ustawienia strony… + .fill-truetype-glyphs-textfill = Wypełniaj glify TrueType (TEXTFILL) + .command-line = Wiersz poleceń + .prompt-lines = Wiersze zachęty + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Tymczasowe wiersze zachęty nad oknem poleceń (CLIPROMPTLINES). + .history-fade-time = Czas zanikania historii + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Jak długo wiersze historii pozostają widoczne; 0 je pomija (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Kliknięcie nakładających się obiektów otwiera selektor + .block-edit = Edycja bloku + .double-click-to-edit-block-in-place-refedit = Dwukrotne kliknięcie edytuje blok w miejscu (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Dwukrotne kliknięcie bloków z atrybutami edytuje atrybuty (ATTEDIT) + .user-preferences = Preferencje użytkownika + .zoom = Powiększenie + .reverse-mouse-wheel-zoom-zoomwheel = Odwróć powiększanie kółkiem myszy (ZOOMWHEEL) + .zoom-factor = Współczynnik powiększenia + .how-far-one-wheel-notch-zooms-zoomfactor = O ile powiększa jedno kliknięcie kółka (ZOOMFACTOR). + .text-and-dimensions = Tekst i wymiary + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT edytuje jeden obiekt i kończy (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Wymiary kontynuowane dziedziczą warstwę i styl wymiaru bazowego (DIMCONTINUEMODE) + .qdim-origin-priority = Priorytet początku QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = Od których punktów mierzy QDIM. Ustawiane też wewnątrz polecenia. + .add-scales-automatically = Dodawaj skale automatycznie + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Które obiekty adnotacyjne przyjmują nowo ustawioną skalę adnotacji (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Jednostki długości, kąta i wstawiania + .drawing-units-2 = Jednostki rysunku… + .drafting = Kreślenie ręczne + .drafting-rotation = Obrót rysowania + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Obraca celownik i siatkę przyciągania w aktywnym LUW (SNAPANG). + .polar-tracking-increment = Przyrost śledzenia biegunowego + .snap-and-grid = Przyciąganie i siatka + .snap-modes-grid-spacing-and-object-snap = Tryby przyciągania, odstęp siatki i przyciąganie do obiektów + .drafting-settings-2 = Ustawienia rysowania… + .modeling-3d = Modelowanie 3D + .display-tools = Narzędzia wyświetlania + .show-the-navigation-cube-navvcube = Pokaż kostkę nawigacji (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Pokaż ikonę LUW (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Umieść ikonę LUW w początku układu (UCSICON ORigin) + .display-resolution = Rozdzielczość wyświetlania + .applies-to-the-current-drawing = dotyczy bieżącego rysunku + .isolines-per-surface = Izolinie na powierzchnię + .show-silhouette-edges-on-solids-dispsilh = Pokaż krawędzie sylwetki na bryłach (DISPSILH) + .surface-density-u = Gęstość powierzchni U + .surface-density-v = Gęstość powierzchni V + .surface-type = Typ powierzchni + .record-the-history-of-composite-solids-solidhist = Zapisuj historię brył złożonych (SOLIDHIST) + .show-solid-history = Pokaż historię brył + .not-available = Niedostępne + .open-folder = Otwórz folder + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Gdzie aplikacja przechowuje własne pliki. Te lokalizacje są stałe. + .configuration = Konfiguracja + .plot-styles = Style wydruku + .autosave-files = Pliki automatycznego zapisu .selection-modes = Tryby zaznaczania .use-shift-to-add-to-selection-pickadd = Użyj klawisza Shift, aby dodać do zaznaczenia (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Przeciąganie rysuje prostokąt zamiast lassa (PICKDRAG) diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 58cf1b3d3..fefe16cea 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1376,6 +1376,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Controla tanto a caixa de seleção visível quanto a área de clique. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Aplicativos + .installed-plugins-and-their-sources = Plug-ins instalados e suas origens + .plugins-2 = Plug-ins… + .keyboard-shortcuts-2 = Atalhos de teclado + .keyboard-shortcuts-3 = Atalhos de teclado… + .command-aliases-2 = Apelidos de comandos + .command-aliases-3 = Apelidos de comandos… + .file-safety = Segurança de arquivos + .automatic-save = Salvamento automático + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minutos entre salvamentos de recuperação em um arquivo .sv$; 0 desativa (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Manter uma cópia .bak ao sobrescrever um desenho (ISAVEBAK) + .plotting = Plotagem + .plot-device-paper-scale-and-plot-styles = Dispositivo de plotagem, papel, escala e estilos de plotagem + .plot-and-page-setup = Plotar e configurar página… + .fill-truetype-glyphs-textfill = Preencher glifos TrueType (TEXTFILL) + .command-line = Linha de comando + .prompt-lines = Linhas de solicitação + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Linhas de solicitação temporárias acima da janela de comandos (CLIPROMPTLINES). + .history-fade-time = Tempo de esmaecimento do histórico + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Quanto tempo as linhas de histórico ficam visíveis; 0 as ignora (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Clicar em objetos sobrepostos abre um seletor + .block-edit = Edição de bloco + .double-click-to-edit-block-in-place-refedit = Duplo clique para editar o bloco no local (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Duplo clique em blocos com atributos para editá-los (ATTEDIT) + .user-preferences = Preferências do usuário + .zoom = Zoom + .reverse-mouse-wheel-zoom-zoomwheel = Inverter o zoom da roda do mouse (ZOOMWHEEL) + .zoom-factor = Fator de zoom + .how-far-one-wheel-notch-zooms-zoomfactor = Quanto um passo da roda amplia (ZOOMFACTOR). + .text-and-dimensions = Texto e cotas + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT edita um objeto e encerra (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Cotas continuadas herdam camada e estilo da cota base (DIMCONTINUEMODE) + .qdim-origin-priority = Prioridade de origem do QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = De quais pontos o QDIM mede. Também ajustável dentro do comando. + .add-scales-automatically = Adicionar escalas automaticamente + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Quais objetos anotativos recebem uma escala de anotação recém-definida (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Unidades de comprimento, ângulo e inserção + .drawing-units-2 = Unidades do desenho… + .drafting = Desenho + .drafting-rotation = Rotação de desenho + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Gira a mira e a grade de precisão no UCS ativo (SNAPANG). + .polar-tracking-increment = Incremento de rastreamento polar + .snap-and-grid = Precisão e grade + .snap-modes-grid-spacing-and-object-snap = Modos de precisão, espaçamento da grade e osnap + .drafting-settings-2 = Configurações de desenho… + .modeling-3d = Modelagem 3D + .display-tools = Ferramentas de exibição + .show-the-navigation-cube-navvcube = Mostrar o cubo de navegação (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Mostrar o ícone UCS (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Colocar o ícone UCS na origem (UCSICON ORigin) + .display-resolution = Resolução de exibição + .applies-to-the-current-drawing = aplica-se ao desenho atual + .isolines-per-surface = Isolinhas por superfície + .show-silhouette-edges-on-solids-dispsilh = Mostrar arestas de silhueta em sólidos (DISPSILH) + .surface-density-u = Densidade de superfície U + .surface-density-v = Densidade de superfície V + .surface-type = Tipo de superfície + .record-the-history-of-composite-solids-solidhist = Registrar o histórico de sólidos compostos (SOLIDHIST) + .show-solid-history = Mostrar o histórico de sólidos + .not-available = Não disponível + .open-folder = Abrir pasta + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Onde o aplicativo guarda seus próprios arquivos. Esses locais são fixos. + .configuration = Configuração + .plot-styles = Estilos de plotagem + .autosave-files = Arquivos de salvamento automático .selection-modes = Modos de seleção .use-shift-to-add-to-selection-pickadd = Usar Shift para adicionar à seleção (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Pressionar e arrastar desenha um retângulo em vez de um laço (PICKDRAG) diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 13ba008b2..8c0d1656f 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1379,6 +1379,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Управляет видимым прицелом выбора и областью щелчка. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Приложения + .installed-plugins-and-their-sources = Установленные плагины и их источники + .plugins-2 = Плагины… + .keyboard-shortcuts-2 = Сочетания клавиш + .keyboard-shortcuts-3 = Сочетания клавиш… + .command-aliases-2 = Псевдонимы команд + .command-aliases-3 = Псевдонимы команд… + .file-safety = Безопасность файлов + .automatic-save = Автосохранение + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Минуты между сохранениями восстановления в файл .sv$; 0 отключает (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Сохранять копию .bak при перезаписи чертежа (ISAVEBAK) + .plotting = Печать + .plot-device-paper-scale-and-plot-styles = Устройство печати, бумага, масштаб и стили печати + .plot-and-page-setup = Печать и параметры листа… + .fill-truetype-glyphs-textfill = Заливать глифы TrueType (TEXTFILL) + .command-line = Командная строка + .prompt-lines = Строки запроса + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Временные строки запроса над окном команд (CLIPROMPTLINES). + .history-fade-time = Время затухания журнала + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Как долго строки журнала остаются видимыми; 0 их пропускает (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Щелчок по перекрывающимся объектам открывает список выбора + .block-edit = Редактирование блока + .double-click-to-edit-block-in-place-refedit = Двойной щелчок редактирует блок на месте (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Двойной щелчок по блокам с атрибутами редактирует атрибуты (ATTEDIT) + .user-preferences = Пользовательские настройки + .zoom = Зумирование + .reverse-mouse-wheel-zoom-zoomwheel = Обратить зумирование колесом мыши (ZOOMWHEEL) + .zoom-factor = Коэффициент зумирования + .how-far-one-wheel-notch-zooms-zoomfactor = Насколько приближает один щелчок колеса (ZOOMFACTOR). + .text-and-dimensions = Текст и размеры + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT редактирует один объект и завершается (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Продолженные размеры наследуют слой и стиль базового размера (DIMCONTINUEMODE) + .qdim-origin-priority = Приоритет начала QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = От каких точек измеряет QDIM. Настраивается и внутри команды. + .add-scales-automatically = Добавлять масштабы автоматически + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Какие аннотативные объекты принимают вновь заданный масштаб аннотаций (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Единицы длины, угла и вставки + .drawing-units-2 = Единицы чертежа… + .drafting = Черчение + .drafting-rotation = Поворот черчения + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Поворачивает перекрестье и сетку привязки в активной ПСК (SNAPANG). + .polar-tracking-increment = Шаг полярного отслеживания + .snap-and-grid = Привязка и сетка + .snap-modes-grid-spacing-and-object-snap = Режимы привязки, шаг сетки и объектная привязка + .drafting-settings-2 = Параметры черчения… + .modeling-3d = 3D-моделирование + .display-tools = Инструменты отображения + .show-the-navigation-cube-navvcube = Показывать куб навигации (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Показывать значок ПСК (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Размещать значок ПСК в начале координат (UCSICON ORigin) + .display-resolution = Разрешение отображения + .applies-to-the-current-drawing = применяется к текущему чертежу + .isolines-per-surface = Изолиний на поверхность + .show-silhouette-edges-on-solids-dispsilh = Показывать силуэтные рёбра тел (DISPSILH) + .surface-density-u = Плотность поверхности U + .surface-density-v = Плотность поверхности V + .surface-type = Тип поверхности + .record-the-history-of-composite-solids-solidhist = Записывать историю составных тел (SOLIDHIST) + .show-solid-history = Показывать историю тел + .not-available = Недоступно + .open-folder = Открыть папку + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Где приложение хранит собственные файлы. Эти расположения фиксированы. + .configuration = Конфигурация + .plot-styles = Стили печати + .autosave-files = Файлы автосохранения .selection-modes = Режимы выбора .use-shift-to-add-to-selection-pickadd = Использовать Shift для добавления к выбору (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Нажатие и перетаскивание рисует прямоугольник вместо лассо (PICKDRAG) diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 0ffa766d1..07441cb23 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -464,8 +464,8 @@ common = .fixed-height = Sabit Yükseklik: .fixed-length-dimfxl = Sabit uzunluk (DIMFXL) .fixed-length-ext-lines-dimfxlon = Sabit uzunluklu uzatma çizgileri (DIMFXLON) - .flat-shaded = Düz Gölgelendirilmiş - .flat-shaded-edges = Düz Gölgelendirilmiş + Kenarlar + .flat-shaded = Düz Gölge + .flat-shaded-edges = Düz Gölge + Kenarlar .flow-direction = Akış yönü: .font-load-failed-script-e = Yazı tipi yüklenemedi (__ocs_fmt_0__): __ocs_fmt_1__ .force-text-inside-dimtix = Metni içeride olmaya zorla (DIMTIX) @@ -479,8 +479,8 @@ common = .general = Genel .generate-tolerances-dimtol = Toleransları oluştur (DIMTOL) .geometry = Geometri - .gouraud-shaded = Gouraud Gölgelendirilmiş - .gouraud-shaded-edges = Gouraud Gölgelendirilmiş + Kenarlar + .gouraud-shaded = Gouraud Gölge + .gouraud-shaded-edges = Gouraud Gölge + Kenarlar .green = Yeşil .group = Grup .group-created = "__ocs_fmt_0__" grubu oluşturuldu. @@ -1362,6 +1362,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Hem görünür seçim kutusunu hem de tıklama alanını denetler. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Uygulamalar + .installed-plugins-and-their-sources = Yüklü eklentiler ve kaynakları + .plugins-2 = Eklentiler… + .keyboard-shortcuts-2 = Klavye kısayolları + .keyboard-shortcuts-3 = Klavye kısayolları… + .command-aliases-2 = Komut takma adları + .command-aliases-3 = Komut takma adları… + .file-safety = Dosya güvenliği + .automatic-save = Otomatik kaydetme + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Bir .sv$ dosyasına kurtarma kayıtları arasındaki dakika; 0 kapatır (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Bir çizimin üzerine yazarken .bak kopyası tut (ISAVEBAK) + .plotting = Çizdirme + .plot-device-paper-scale-and-plot-styles = Çizim aygıtı, kâğıt, ölçek ve çizim stilleri + .plot-and-page-setup = Çizdir ve sayfa düzeni… + .fill-truetype-glyphs-textfill = TrueType karakterlerini doldur (TEXTFILL) + .command-line = Komut satırı + .prompt-lines = İstem satırları + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Komut penceresinin üstündeki geçici istem satırları (CLIPROMPTLINES). + .history-fade-time = Geçmiş solma süresi + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Geçmiş satırlarının ne kadar görünür kalacağı; 0 atlar (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Üst üste binen nesnelere tıklamak bir seçici açar + .block-edit = Blok düzenleme + .double-click-to-edit-block-in-place-refedit = Bloğu yerinde düzenlemek için çift tıklayın (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Öznitelikli bloklara çift tıklayarak özniteliklerini düzenleyin (ATTEDIT) + .user-preferences = Kullanıcı tercihleri + .zoom = Yakınlaştırma + .reverse-mouse-wheel-zoom-zoomwheel = Fare tekerleği yakınlaştırmasını ters çevir (ZOOMWHEEL) + .zoom-factor = Yakınlaştırma çarpanı + .how-far-one-wheel-notch-zooms-zoomfactor = Bir tekerlek adımının ne kadar yakınlaştıracağı (ZOOMFACTOR). + .text-and-dimensions = Metin ve ölçülendirme + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT bir nesneyi düzenler ve biter (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Devam eden ölçüler temel ölçünün katmanını ve stilini devralır (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM başlangıç önceliği + .which-points-qdim-measures-from-also-settable-inside-the-command = QDIM'in hangi noktalardan ölçtüğü. Komutun içinden de ayarlanır. + .add-scales-automatically = Ölçekleri otomatik ekle + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Yeni ayarlanan bir açıklama ölçeğini hangi açıklamalı nesnelerin alacağı (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Uzunluk, açı ve ekleme birimleri + .drawing-units-2 = Çizim birimleri… + .drafting = Taslak + .drafting-rotation = Taslak dönüşü + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Etkin UCS'de artı imleci ve yakalama ızgarasını döndürür (SNAPANG). + .polar-tracking-increment = Kutupsal izleme artışı + .snap-and-grid = Yakalama ve ızgara + .snap-modes-grid-spacing-and-object-snap = Yakalama modları, ızgara aralığı ve nesne yakalama + .drafting-settings-2 = Taslak ayarları… + .modeling-3d = 3B modelleme + .display-tools = Görüntüleme araçları + .show-the-navigation-cube-navvcube = Gezinme küpünü göster (NAVVCUBE) + .show-the-ucs-icon-ucsicon = UCS simgesini göster (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = UCS simgesini başlangıç noktasına yerleştir (UCSICON ORigin) + .display-resolution = Görüntüleme çözünürlüğü + .applies-to-the-current-drawing = geçerli çizim için geçerlidir + .isolines-per-surface = Yüzey başına eş çizgiler + .show-silhouette-edges-on-solids-dispsilh = Katılarda siluet kenarlarını göster (DISPSILH) + .surface-density-u = Yüzey yoğunluğu U + .surface-density-v = Yüzey yoğunluğu V + .surface-type = Yüzey türü + .record-the-history-of-composite-solids-solidhist = Bileşik katıların geçmişini kaydet (SOLIDHIST) + .show-solid-history = Katı geçmişini göster + .not-available = Kullanılamıyor + .open-folder = Klasörü aç + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Uygulamanın kendi dosyalarını tuttuğu yer. Bu konumlar sabittir. + .configuration = Yapılandırma + .plot-styles = Çizim stilleri + .autosave-files = Otomatik kaydetme dosyaları .selection-modes = Seçim modları .use-shift-to-add-to-selection-pickadd = Seçime eklemek için Shift kullan (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Basılı tutup sürükleme kement yerine dikdörtgen çizer (PICKDRAG) @@ -3433,7 +3498,7 @@ view = .syncpviewports-master-is-not-a-viewport = SYNCPVIEWPORTS: Ana nesne bir görünüm alanı değil. .syncpviewports-synced-n-viewport-s-to-the-mast = SYNCPVIEWPORTS: __ocs_fmt_0__ görünüm alanı ana görünüm alanıyla eşitlendi. .scale-arg-already-exists = __ocs_fmt_0__ ölçeği zaten var. - .shaded = Gölgelendirilmiş + .shaded = Gölge .sheet-set-manager = Pafta Seti Yöneticisi .shortcut-key-not-found = '__ocs_fmt_0__' kısayolu bulunamadı. .shortcut-key-removed = '__ocs_fmt_0__' kısayolu kaldırıldı. @@ -4365,7 +4430,7 @@ styles = .usage-lwdisplay-on-off = Kullanım: LWDISPLAY [ON|OFF] .usage-rename-type-old-new-types-layer-block-st = Kullanım: RENAME (türler: LAYER BLOCK STYLE DIMSTYLE LINETYPE UCS VIEW) .usage-scaletext-factor-or-scaletext-h-height = Kullanım: SCALETEXT veya SCALETEXT H - .vscurrent-visual-style-shaded-wireframe-hidden = VSCURRENT Görsel stil [Gölgelendirilmiş / Tel Kafes / Gizli / Gerçekçi / Kavramsal / X Işını]: + .vscurrent-visual-style-shaded-wireframe-hidden = VSCURRENT Görsel stil [Gölge / Tel Kafes / Gizli / Gerçekçi / Kavramsal / X Işını]: .windows-desktop = Windows masaüstü .big-font-file = büyük yazı tipi dosyası… .font-file-picker-lowercase = yazı tipi dosyası… diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index b737e29f0..e7a4a81a2 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1363,6 +1363,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = 同时控制可见选择框和单击范围。 .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = 应用程序 + .installed-plugins-and-their-sources = 已安装的插件及其来源 + .plugins-2 = 插件… + .keyboard-shortcuts-2 = 键盘快捷键 + .keyboard-shortcuts-3 = 键盘快捷键… + .command-aliases-2 = 命令别名 + .command-aliases-3 = 命令别名… + .file-safety = 文件安全 + .automatic-save = 自动保存 + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = 两次保存到 .sv$ 恢复文件之间的分钟数;0 表示关闭 (SAVETIME)。 + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = 覆盖图形时保留 .bak 副本 (ISAVEBAK) + .plotting = 打印 + .plot-device-paper-scale-and-plot-styles = 打印设备、图纸、比例和打印样式 + .plot-and-page-setup = 打印和页面设置… + .fill-truetype-glyphs-textfill = 填充 TrueType 字形 (TEXTFILL) + .command-line = 命令行 + .prompt-lines = 提示行 + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = 命令窗口上方显示的临时提示行 (CLIPROMPTLINES)。 + .history-fade-time = 历史淡出时间 + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = 历史行保持可见的时长;0 表示跳过 (COMMANDLINEFADETIME)。 + .clicking-overlapping-objects-opens-a-picker = 单击重叠对象时打开选择器 + .block-edit = 块编辑 + .double-click-to-edit-block-in-place-refedit = 双击就地编辑块 (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = 双击带属性的块以编辑属性 (ATTEDIT) + .user-preferences = 用户首选项 + .zoom = 缩放 + .reverse-mouse-wheel-zoom-zoomwheel = 反转鼠标滚轮缩放方向 (ZOOMWHEEL) + .zoom-factor = 缩放系数 + .how-far-one-wheel-notch-zooms-zoomfactor = 滚轮每格的缩放幅度 (ZOOMFACTOR)。 + .text-and-dimensions = 文字和标注 + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT 编辑一个对象后结束 (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = 连续标注继承基准标注的图层和样式 (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM 原点优先级 + .which-points-qdim-measures-from-also-settable-inside-the-command = QDIM 从哪些点测量。也可在命令内设置。 + .add-scales-automatically = 自动添加比例 + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = 哪些注释性对象接受新设置的注释比例 (ANNOAUTOSCALE)。 + .length-angle-and-insertion-units = 长度、角度和插入单位 + .drawing-units-2 = 图形单位… + .drafting = 草图 + .drafting-rotation = 草图旋转 + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = 在当前 UCS 中旋转十字光标和捕捉栅格 (SNAPANG)。 + .polar-tracking-increment = 极轴追踪增量 + .snap-and-grid = 捕捉和栅格 + .snap-modes-grid-spacing-and-object-snap = 捕捉模式、栅格间距和对象捕捉 + .drafting-settings-2 = 草图设置… + .modeling-3d = 三维建模 + .display-tools = 显示工具 + .show-the-navigation-cube-navvcube = 显示导航立方体 (NAVVCUBE) + .show-the-ucs-icon-ucsicon = 显示 UCS 图标 (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = 将 UCS 图标置于原点 (UCSICON ORigin) + .display-resolution = 显示分辨率 + .applies-to-the-current-drawing = 应用于当前图形 + .isolines-per-surface = 每个曲面的素线数 + .show-silhouette-edges-on-solids-dispsilh = 在实体上显示轮廓边 (DISPSILH) + .surface-density-u = 曲面密度 U + .surface-density-v = 曲面密度 V + .surface-type = 曲面类型 + .record-the-history-of-composite-solids-solidhist = 记录复合实体的历史 (SOLIDHIST) + .show-solid-history = 显示实体历史 + .not-available = 不可用 + .open-folder = 打开文件夹 + .where-the-application-keeps-its-own-files-these-locations-are-fixed = 应用程序存放自身文件的位置。这些位置是固定的。 + .configuration = 配置 + .plot-styles = 打印样式 + .autosave-files = 自动保存文件 .selection-modes = 选择模式 .use-shift-to-add-to-selection-pickadd = 使用 Shift 添加到选择集 (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = 按住并拖动时绘制矩形而非套索 (PICKDRAG) diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index bc7a4f144..446aa9b4f 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1375,6 +1375,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = 同時控制可見選取框與點選範圍。 .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = 應用程式 + .installed-plugins-and-their-sources = 已安裝的外掛程式及其來源 + .plugins-2 = 外掛程式… + .keyboard-shortcuts-2 = 鍵盤快速鍵 + .keyboard-shortcuts-3 = 鍵盤快速鍵… + .command-aliases-2 = 指令別名 + .command-aliases-3 = 指令別名… + .file-safety = 檔案安全 + .automatic-save = 自動儲存 + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = 兩次儲存到 .sv$ 復原檔之間的分鐘數;0 表示關閉 (SAVETIME)。 + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = 覆寫圖面時保留 .bak 副本 (ISAVEBAK) + .plotting = 出圖 + .plot-device-paper-scale-and-plot-styles = 出圖裝置、紙張、比例與出圖樣式 + .plot-and-page-setup = 出圖與頁面設定… + .fill-truetype-glyphs-textfill = 填滿 TrueType 字符 (TEXTFILL) + .command-line = 指令行 + .prompt-lines = 提示行 + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = 指令視窗上方顯示的暫時提示行 (CLIPROMPTLINES)。 + .history-fade-time = 歷程淡出時間 + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = 歷程行保持顯示的時間;0 表示略過 (COMMANDLINEFADETIME)。 + .clicking-overlapping-objects-opens-a-picker = 點選重疊的物件時開啟選擇器 + .block-edit = 圖塊編輯 + .double-click-to-edit-block-in-place-refedit = 雙擊以就地編輯圖塊 (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = 雙擊具屬性的圖塊以編輯屬性 (ATTEDIT) + .user-preferences = 使用者偏好設定 + .zoom = 縮放 + .reverse-mouse-wheel-zoom-zoomwheel = 反轉滑鼠滾輪縮放方向 (ZOOMWHEEL) + .zoom-factor = 縮放係數 + .how-far-one-wheel-notch-zooms-zoomfactor = 滾輪每一格的縮放幅度 (ZOOMFACTOR)。 + .text-and-dimensions = 文字與標註 + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT 編輯一個物件後結束 (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = 連續標註繼承基準標註的圖層與樣式 (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM 原點優先順序 + .which-points-qdim-measures-from-also-settable-inside-the-command = QDIM 從哪些點量測。也可在指令內設定。 + .add-scales-automatically = 自動加入比例 + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = 哪些註解性物件接受新設定的註解比例 (ANNOAUTOSCALE)。 + .length-angle-and-insertion-units = 長度、角度與插入單位 + .drawing-units-2 = 圖面單位… + .drafting = 製圖 + .drafting-rotation = 製圖旋轉 + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = 在使用中的 UCS 內旋轉十字游標與鎖點格線 (SNAPANG)。 + .polar-tracking-increment = 極座標追蹤增量 + .snap-and-grid = 鎖點與格線 + .snap-modes-grid-spacing-and-object-snap = 鎖點模式、格線間距與物件鎖點 + .drafting-settings-2 = 製圖設定… + .modeling-3d = 3D 塑型 + .display-tools = 顯示工具 + .show-the-navigation-cube-navvcube = 顯示導覽方塊 (NAVVCUBE) + .show-the-ucs-icon-ucsicon = 顯示 UCS 圖示 (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = 將 UCS 圖示置於原點 (UCSICON ORigin) + .display-resolution = 顯示解析度 + .applies-to-the-current-drawing = 套用至目前的圖面 + .isolines-per-surface = 每個曲面的等值線 + .show-silhouette-edges-on-solids-dispsilh = 在實體上顯示輪廓邊 (DISPSILH) + .surface-density-u = 曲面密度 U + .surface-density-v = 曲面密度 V + .surface-type = 曲面類型 + .record-the-history-of-composite-solids-solidhist = 記錄複合實體的歷程 (SOLIDHIST) + .show-solid-history = 顯示實體歷程 + .not-available = 無法使用 + .open-folder = 開啟資料夾 + .where-the-application-keeps-its-own-files-these-locations-are-fixed = 應用程式存放自身檔案的位置。這些位置是固定的。 + .configuration = 組態 + .plot-styles = 出圖樣式 + .autosave-files = 自動儲存檔案 .selection-modes = 選取模式 .use-shift-to-add-to-selection-pickadd = 使用 Shift 加入選取集 (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = 按住並拖曳時繪製矩形而非套索 (PICKDRAG) diff --git a/scripts/build-site.py b/scripts/build-site.py index 66afa6aff..98ab188c1 100644 --- a/scripts/build-site.py +++ b/scripts/build-site.py @@ -18,6 +18,10 @@ def locale_path(locale): return f"/{locale}/" +def public_path(locale): + return "/" if locale == "en-US" else locale_path(locale) + + def load_catalogs(): catalogs = {p.stem: json.loads(p.read_text()) for p in sorted((ROOT / "site/locales").glob("*.json"))} supported = {p.parent.name for p in (ROOT / "locales").glob("*/opencadstudio.ftl")} @@ -65,14 +69,14 @@ def build(output): script = "const SITE_LOCALES = " + json.dumps(list(catalogs)) + ";\n" + (ROOT / "site/site.js").read_text() (output / "site.js").write_text(script, encoding="utf-8") js_version = hashlib.sha256(script.encode()).hexdigest()[:12] - alternates = '\n'.join(f'' for lang in catalogs) + alternates = '\n'.join(f'' for lang in catalogs) alternates += f'\n' for locale, messages in catalogs.items(): - path = locale_path(locale) - destination = output / path.lstrip("/") + path = public_path(locale) + destination = output / locale_path(locale).lstrip("/") destination.mkdir(parents=True, exist_ok=True) language_links = '\n'.join( - f'{escape(labels["name"])}' for lang, labels in catalogs.items() ) @@ -85,6 +89,11 @@ def build(output): "downloadUrl": REPO + "/releases/latest", "installUrl": BASE + "/app/", "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"}, } + website_schema = { + "@context": "https://schema.org", "@type": "WebSite", + "name": "Open CAD Studio", "alternateName": ["OpenCADStudio", "opencadstudio.com"], + "url": BASE + "/", + } values = {key: escape(value) for key, value in messages.items()} values.update(locale=locale, direction="rtl" if locale == "ar-SA" else "ltr", path=path, url=BASE + path, og_locale=locale.replace("-", "_"), @@ -92,6 +101,8 @@ def build(output): css_version=css_version, js_version=js_version, alternates=alternates, languages=escape(messages["languages"].format(count=len(catalogs))), language_links=language_links, + website_schema=(f'' + if locale == "en-US" else ""), schema=json.dumps(schema, ensure_ascii=False).replace("<", "\\u003c")) rendered = re.sub(r"\{\{(\w+)\}\}", lambda match: values[match[1]], template) (destination / "index.html").write_text(rendered, encoding="utf-8") @@ -101,7 +112,7 @@ def build(output): (destination / "site.webmanifest").write_text(json.dumps(localized_manifest, ensure_ascii=False, indent=2) + '\n', encoding="utf-8") for filename in ("index.html", "site.webmanifest"): shutil.copyfile(output / "en-US" / filename, output / filename) - urls = f'{BASE}/\n' + ''.join(f'{BASE}{locale_path(locale)}\n' for locale in catalogs) + urls = ''.join(f'{BASE}{public_path(locale)}\n' for locale in catalogs) (output / "sitemap.xml").write_text('\n\n' + urls + '\n') app = output / "app/index.html" diff --git a/scripts/test_site.py b/scripts/test_site.py index a13055589..727bf6be6 100644 --- a/scripts/test_site.py +++ b/scripts/test_site.py @@ -12,9 +12,6 @@ from urllib.parse import urlsplit import xml.etree.ElementTree as ET ROOT = Path(__file__).resolve().parents[1] -subprocess.run(['python3', 'scripts/test_locales.py'], cwd=ROOT, check=True) -subprocess.run(['python3', 'scripts/export-locales.py', '--check'], cwd=ROOT, check=True) -subprocess.run(['node', 'scripts/test_web_locales.cjs'], cwd=ROOT, check=True) class Page(HTMLParser): @@ -49,7 +46,7 @@ with tempfile.TemporaryDirectory() as directory: svg = charts.render_svg('HakanSeven12/OpenCADStudio', [date], [('v1', date, 10)], 'dark', chart, messages) tree = ET.fromstring(svg) assert tree.find('{http://www.w3.org/2000/svg}title').text == messages['stars' if chart == 'stars' else 'downloads'] - (output / locale / f'{chart[:-1]}-history-dark.svg').write_text(svg) + (output / ('' if locale == 'en-US' else locale) / f'{chart[:-1]}-history-dark.svg').write_text(svg) html = (output / locale / 'index.html').read_text() assert '{{' not in html and '{count}' not in html, locale page = Page(html) @@ -57,7 +54,8 @@ with tempfile.TemporaryDirectory() as directory: links = [attrs for tag, attrs in page.tags if tag == 'link'] assert {link['href'] for link in links if link.get('rel') == 'icon'} == {'/favicon.ico', '/favicon.png', '/favicon.svg'} assert {link['hreflang'] for link in links if link.get('rel') == 'alternate'} == supported | {'x-default'} - assert next(link['href'] for link in links if link.get('rel') == 'canonical') == f'https://www.opencadstudio.com/{locale}/' + expected_path = '/' if locale == 'en-US' else f'/{locale}/' + assert next(link['href'] for link in links if link.get('rel') == 'canonical') == f'https://www.opencadstudio.com{expected_path}' choices = [attrs for tag, attrs in page.tags if tag == 'a' and 'hreflang' in attrs] assert {choice['hreflang'] for choice in choices} == supported assert [choice['hreflang'] for choice in choices if choice.get('aria-current') == 'page'] == [locale] @@ -74,7 +72,7 @@ with tempfile.TemporaryDirectory() as directory: path = output / urlsplit(target).path.lstrip('/') assert path.exists(), (locale, target) manifest = json.loads((output / locale / 'site.webmanifest').read_text()) - assert manifest['lang'] == locale and manifest['start_url'] == f'/{locale}/' + assert manifest['lang'] == locale and manifest['start_url'] == expected_path for icon in manifest['icons']: assert (output / icon['src'].lstrip('/')).exists() if locale != 'en-US': @@ -87,7 +85,13 @@ with tempfile.TemporaryDirectory() as directory: assert icon == '/favicon.svg' assert (output / icon.lstrip('/')).read_bytes() == (ROOT / 'site/favicon.svg').read_bytes() sitemap = ET.parse(output / 'sitemap.xml') - assert len(sitemap.getroot()) == len(supported) + 1 + locations = {url[0].text for url in sitemap.getroot()} + assert len(locations) == len(supported) + assert 'https://www.opencadstudio.com/' in locations + assert 'https://www.opencadstudio.com/en-US/' not in locations assert (output / 'index.html').read_bytes() == (output / 'en-US/index.html').read_bytes() + homepage_schemas = [json.loads(value) for value in re.findall(r'', (output / 'index.html').read_text())] + website = next(schema for schema in homepage_schemas if schema['@type'] == 'WebSite') + assert website['url'] == 'https://www.opencadstudio.com/' and 'OpenCADStudio' in website['alternateName'] subprocess.run(['node', 'scripts/test_site.cjs', str(output / 'site.js')], cwd=ROOT, check=True) print(f'All {len(supported)} translations, metadata, local links, charts, manifests and icons passed') diff --git a/src/app/automation.rs b/src/app/automation.rs index 132690e05..5796c5e61 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -1516,4 +1516,101 @@ mod tests { // Finish let _ = app.update(Message::CommandOptionPick(String::new())); } + + #[test] + fn test_mtp_in_line_command() { + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + { + app.tabs[0].scene.selection.borrow_mut().vp_size = (1920.0, 1080.0); + app.tabs[0].scene.sync_tiles_from_panes(1920.0, 1080.0); + } + + // Start LINE + let _ = app.update(Message::CommandInput("LINE".to_string())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + + // Type M2P + let _ = app.update(Message::CommandInput("M2P".to_string())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); + assert!(app.tabs[0].suspended_cmd.is_some()); + assert_eq!(app.tabs[0].suspended_cmd.as_ref().map(|c| c.name()), Some("LINE")); + + // Point 1: 0,0 + let _ = app.update(Message::CommandInput("0,0".to_string())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); + + // Point 2: 10,20 + let _ = app.update(Message::CommandInput("10,20".to_string())); + let _ = app.update(Message::CommandSubmit); + + // MTP should have finished and restored LINE, with midpoint (5, 10, 0) + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert!(app.tabs[0].suspended_cmd.is_none()); + assert_eq!(app.last_point, Some(glam::DVec3::new(5.0, 10.0, 0.0))); + + // Cancel LINE + let _ = app.update(Message::CommandEscape); + assert!(app.tabs[0].active_cmd.is_none()); + } + + #[test] + fn test_mtp_escape_restores_parent() { + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + + // Start LINE + let _ = app.update(Message::CommandInput("LINE".to_string())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + + // Type MTP + let _ = app.update(Message::CommandInput("MTP".to_string())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); + + // Escape during MTP + let _ = app.update(Message::CommandEscape); + // Parent LINE must be restored, not cancelled! + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert!(app.tabs[0].suspended_cmd.is_none()); + + // Escape again cancels LINE + let _ = app.update(Message::CommandEscape); + assert!(app.tabs[0].active_cmd.is_none()); + } + + #[test] + fn test_mtp_typing_routing_with_dyn_input() { + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.dyn_input = true; + + // Start LINE + let _ = app.update(Message::CommandInput("LINE".to_string())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + + // Simulate typing 'm', '2', 'p' character by character + let _ = app.update(Message::CommandAppendChar("m".to_string())); + assert_eq!(app.command_line.input, "M"); + + // The digit '2' must stay in command_line.input instead of routing to dyn fields! + let _ = app.update(Message::CommandAppendChar("2".to_string())); + assert_eq!(app.command_line.input, "M2"); + + let _ = app.update(Message::CommandAppendChar("p".to_string())); + assert_eq!(app.command_line.input, "M2P"); + + // Submit triggers MTP + let _ = app.update(Message::CommandSubmit); + 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 218067ef5..2d9d6167b 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -671,6 +671,29 @@ impl OpenCADStudio { } return Task::none(); } + let is_mtp = token.trim_start_matches('_').eq_ignore_ascii_case("MTP") + || token.trim_start_matches('_').eq_ignore_ascii_case("M2P"); + if is_mtp { + let is_point_step = !self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.input_kind().wants_text()) + .unwrap_or(true) + || self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.point_step_accepts_keywords()) + .unwrap_or(false); + let not_entity_pick = !self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.needs_entity_pick()) + .unwrap_or(false); + if is_point_step && not_entity_pick { + self.start_mtp_modifier(i); + return Task::none(); + } + } if let Some((coord, kind)) = super::helpers::parse_coord(token) { // Match the GUI command line: typed coordinates are in the active // UCS (relative offsets are rotated by the UCS axes), so a multi- @@ -712,7 +735,22 @@ impl OpenCADStudio { /// pick-first selector relaunching MOVE on the picked set works this way). /// Pure-selection commands (SELECTALL, QSELECT, …) run without an active /// command, so `was_active` is false and their selection is preserved. - pub(super) fn apply_cmd_result(&mut self, result: CmdResult) -> Task { + pub(super) fn apply_cmd_result(&mut self, mut result: CmdResult) -> Task { + let i = self.active_tab; + if let CmdResult::ReturnPoint(pt) = result { + self.tabs[i].scene.clear_preview_wire(); + if let Some(mut suspended) = self.tabs[i].suspended_cmd.take() { + self.last_point = Some(pt); + let next_res = suspended.on_point(pt); + self.tabs[i].active_cmd = Some(suspended); + result = next_res; + } else { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + return Task::none(); + } + } let settings = self.tabs[self.active_tab] .active_cmd .as_ref() @@ -1192,6 +1230,18 @@ impl OpenCADStudio { 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; + self.tabs[i].scene.clear_preview_wire(); + let cmd = crate::command::Mid2PointCommand::new(); + let prompt = crate::command::CadCommand::prompt(&cmd); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + self.command_line.push_info(&prompt); + self.command_line.set_step_options(Vec::new()); + self.refresh_active_cmd_preview(i); + } + fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; let preserve_commit_layer = self.tabs[i] @@ -2108,7 +2158,7 @@ impl OpenCADStudio { } => { let label = self.history_label_from_active_cmd(i, "SOLID"); let erase_source = erase_source.filter(|handle| { - self.tabs[i].scene.document.header.delete_objects + self.delete_objects != 0 && !self.tabs[i].scene.is_layer_locked(*handle) }); let pending = self.begin_undo( @@ -2876,14 +2926,30 @@ impl OpenCADStudio { cancel @ (CmdResult::Cancel | CmdResult::CancelForSpaceChange) => { let space_changed = matches!(cancel, CmdResult::CancelForSpaceChange); self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - self.command_line.push_info(crate::t!(if space_changed { - "Command cancelled because the active drawing space changed." + if !space_changed && self.tabs[i].suspended_cmd.is_some() { + self.tabs[i].active_cmd = self.tabs[i].suspended_cmd.take(); + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + let opts = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.options()) + .unwrap_or_default(); + self.command_line.set_step_options(opts); + self.refresh_active_cmd_preview(i); } else { - "Command cancelled." - }).as_ref()); + self.tabs[i].suspended_cmd = None; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.command_line.push_info(crate::t!(if space_changed { + "Command cancelled because the active drawing space changed." + } else { + "Command cancelled." + }).as_ref()); + } } CmdResult::SelectByPath { path, @@ -2971,7 +3037,7 @@ impl OpenCADStudio { CmdResult::EditTableCell { handle, point } => { // TABLEDIT's pick: end the pick phase and hand (table, point) // to the shared cell-edit launcher. A miss or a locked cell - // re-prompts, matching AutoCAD's select-until-valid loop. + // re-prompts until a valid table cell is selected. use crate::modules::annotate::table_cmd::{table_cell_at, TableCellEditStart}; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -4098,7 +4164,7 @@ impl OpenCADStudio { taper_angle, color: _, } => { - let delete_sources = self.tabs[i].scene.document.header.delete_objects; + let delete_sources = self.delete_objects != 0; if handles.is_empty() || handles .iter() @@ -4193,10 +4259,36 @@ impl OpenCADStudio { let direction = direction .or(path_direction) .unwrap_or(glam::DVec3::ZERO); - self.add_surface_model( - empty_extruded_surface(direction, taper_angle), - body, - ) + let history = path + .is_none() + .then(|| { + sweep_model::extrusion_history( + &entity, + None, + direction.to_array(), + taper_angle, + profile.plane.origin, + ) + }) + .flatten(); + let mut surface = empty_extruded_surface(direction, taper_angle); + if let ( + Some(acadrust::objects::SolidHistoryOperation::Extrusion(value)), + acadrust::EntityType::Surface(entity), + ) = (&history, &mut surface) + { + if let Some(data) = + crate::scene::model::solid_history::extrusion_surface_data(value) + { + entity.surface_data = data; + } + } + match history { + Some(history) => { + self.add_surface_model_with_history(surface, body, history) + } + None => self.add_surface_model(surface, body), + } } else { let direction = direction.unwrap_or(glam::DVec3::ZERO); let history = path @@ -4385,7 +4477,7 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); return Task::none(); } - let delete_sources = self.tabs[i].scene.document.header.delete_objects; + let delete_sources = self.delete_objects != 0; use crate::command::ExtrudeMode; use crate::modules::insert::solid3d_cmds::{ empty_revolved_surface, empty_solid3d, @@ -4519,7 +4611,8 @@ impl OpenCADStudio { use crate::command::ExtrudeMode; use crate::modules::insert::solid3d_cmds::empty_solid3d; use crate::scene::model::sweep_model; - let delete_sources = self.tabs[i].scene.document.header.delete_objects; + let delete_objects = self.delete_objects; + let delete_profiles = delete_objects != 0; let path = self.tabs[i].scene.document.get_entity(path_handle).cloned(); let mut profiles = Vec::new(); let mut failed = 0usize; @@ -4557,6 +4650,14 @@ impl OpenCADStudio { failed += 1; continue; }; + let deletes_path = crate::app::delobj_deletes_auxiliary( + delete_objects, + surface, + ); + if deletes_path && self.tabs[i].scene.is_layer_locked(path_handle) { + failed += 1; + continue; + } let created = if surface { self.add_surface_model(sweep_model::swept_surface_entity(&record), body) } else { @@ -4569,9 +4670,12 @@ impl OpenCADStudio { failed += 1; } else { created_handles.push(created); - if delete_sources { + if delete_profiles { consumed.push(handle); } + if deletes_path { + consumed.push(path_handle); + } } } if !created_handles.is_empty() { @@ -4612,12 +4716,14 @@ impl OpenCADStudio { }).collect::>(); sources.sort_unstable_by_key(|handle| handle.value()); sources.dedup(); - let delete_sources = self.tabs[i].scene.document.header.delete_objects; - let locked = delete_sources && sources.iter().any(|handle| self.tabs[i].scene.is_layer_locked(*handle)); + let delete_objects = self.delete_objects; + let delete_sections = delete_objects != 0; + let section_locked = delete_sections + && sources.iter().any(|handle| self.tabs[i].scene.is_layer_locked(*handle)); let available = sources.iter().chain(guides.iter()).copied().chain(path) .filter_map(|handle| self.tabs[i].scene.document.get_entity(handle) .cloned().map(|entity| (handle, entity))).collect::>(); - let result = if locked { + let result = if section_locked { Err("LOFT: a source is on a locked layer; disable source deletion or unlock it.".to_string()) } else { loft_command_model::record(§ions, &guides, path, &available, mode, options) @@ -4626,6 +4732,19 @@ impl OpenCADStudio { match result { Ok((body, record)) => { let surface = record.parameters.as_ref().is_some_and(|settings| settings.surface); + let delete_auxiliary = crate::app::delobj_deletes_auxiliary( + delete_objects, + surface, + ); + if delete_auxiliary + && guides.iter().copied().chain(path) + .any(|handle| self.tabs[i].scene.is_layer_locked(handle)) + { + self.command_line.push_error( + "LOFT: a source is on a locked layer; disable source deletion or unlock it.", + ); + return Task::none(); + } let dirty_before = self.tabs[i].dirty; let pending = self.begin_undo(i, "LOFT", 1, true); let created = if surface { @@ -4647,7 +4766,18 @@ impl OpenCADStudio { self.command_line.push_error(crate::t!("LOFT could not create a complete display. The source sections were preserved.").as_ref()); return Task::none(); } else { - if delete_sources { self.tabs[i].scene.erase_entities(&sources); } + let mut consumed = if delete_sections { + sources.clone() + } else { + Vec::new() + }; + if delete_auxiliary { + consumed.extend(guides.iter().copied()); + consumed.extend(path); + } + consumed.sort_unstable_by_key(|handle| handle.value()); + consumed.dedup(); + self.tabs[i].scene.erase_entities(&consumed); self.tabs[i].scene.deselect_all(); self.tabs[i].scene.select_entity(created, true); self.tabs[i].dirty = true; @@ -5124,6 +5254,12 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); } + CmdResult::ReturnPoint(_) => { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + } } // When no command is running the ribbon tool button still has to // visually deactivate. Keyboard focus is assigned below to whichever @@ -6682,6 +6818,71 @@ mod sketch_constraint_undo_tests { } } +#[cfg(all(test, not(target_arch = "wasm32")))] +mod delobj_tests { + use super::*; + use acadrust::types::Vector3; + + fn sweep_source_presence(value: i16, mode: crate::command::ExtrudeMode) -> (bool, bool) { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let mut circle = acadrust::Circle::new(); + circle.radius = 2.0; + let profile = app.tabs[i] + .scene + .add_entity(acadrust::EntityType::Circle(circle)); + let path = app.tabs[i].scene.add_entity(acadrust::EntityType::Line( + acadrust::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(0.0, 0.0, 5.0), + ), + )); + app.delete_objects = value; + + let _ = app.apply_cmd_result(CmdResult::SweepEntities { + handles: vec![profile], + path_handle: path, + mode, + options: crate::command::SweepOptions::default(), + color: [1.0; 4], + }); + + let created = app.tabs[i].scene.document.entities().any(|entity| match mode { + crate::command::ExtrudeMode::Solid => { + matches!(entity, acadrust::EntityType::Solid3D(_)) + } + crate::command::ExtrudeMode::Surface => { + matches!(entity, acadrust::EntityType::Surface(_)) + } + }); + assert!(created, "SWEEP must produce the requested result"); + ( + app.tabs[i].scene.document.get_entity(profile).is_some(), + app.tabs[i].scene.document.get_entity(path).is_some(), + ) + } + + #[test] + fn sweep_applies_all_four_source_deletion_modes() { + let solid_expectations = [(true, true), (false, true), (false, false), (false, false)]; + for (value, expected) in solid_expectations.into_iter().enumerate() { + assert_eq!( + sweep_source_presence(value as i16, crate::command::ExtrudeMode::Solid), + expected, + ); + } + assert_eq!( + sweep_source_presence(2, crate::command::ExtrudeMode::Surface), + (false, false), + ); + assert_eq!( + sweep_source_presence(3, crate::command::ExtrudeMode::Surface), + (false, true), + ); + } +} + #[cfg(all(test, not(target_arch = "wasm32")))] mod thicken_tests { use super::*; diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 908b02960..91cdaca00 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -502,13 +502,9 @@ impl OpenCADStudio { self.command_line.push_output(crate::t!("REDRAWALL: viewports refreshed.").as_ref()); return Some(Task::none()); } - // REGEN — full model regeneration (bump_geometry: geometry_epoch AND - // block_epoch; C4). No undo, no DB mutation, so do NOT touch - // self.tabs[i].dirty — a newly opened drawing must not become - // "modified" merely because tessellation caches were invalidated (C7). - // REGENALL is functionally identical (C5). + // Rebuild tessellation caches without modifying the document. "REGEN" | "REGENALL" => { - self.tabs[i].scene.bump_geometry(); + self.tabs[i].scene.populate_meshes_from_document(); self.command_line.push_output(crate::t!("REGEN: regenerated model.").as_ref()); return Some(Task::none()); } @@ -1892,4 +1888,86 @@ mod tests { let _ = app.run_command_line("REGENALL"); assert!(!app.tabs[i].dirty, "REGENALL must not dirty the document either"); } + + #[test] + fn regen_rebuilds_the_mesh_map_rather_than_only_bumping_the_epoch() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + + let stale = acadrust::Handle::new(0xDEAD); + app.tabs[i].scene.meshes.insert(stale, stale_mesh()); + let epoch_before = app.tabs[i].scene.geometry_epoch; + + let _ = app.run_command_line("REGEN"); + + assert!( + !app.tabs[i].scene.meshes.contains_key(&stale), + "REGEN left a stale mesh", + ); + assert_ne!( + app.tabs[i].scene.geometry_epoch, epoch_before, + "REGEN did not bump the geometry epoch", + ); + } + + #[test] + fn the_isolines_slider_rebuilds_once_on_release_and_not_while_dragging() { + use crate::app::Message; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + + let seed = |app: &mut OpenCADStudio, i: usize| { + let stale = acadrust::Handle::new(0xBEEF); + app.tabs[i].scene.meshes.insert(stale, stale_mesh()); + stale + }; + let stale = seed(&mut app, i); + + for value in [4i16, 3, 2, 1, 0] { + let _ = app.update(Message::IsolinesChanged(value)); + } + assert!( + app.tabs[i].scene.meshes.contains_key(&stale), + "the drag itself must not rebuild the meshes", + ); + assert_eq!(app.tabs[i].scene.document.header.isolines, 0); + + let _ = app.update(Message::IsolinesReleased); + assert!( + !app.tabs[i].scene.meshes.contains_key(&stale), + "releasing after a change rebuilds them", + ); + + let stale = seed(&mut app, i); + let _ = app.update(Message::IsolinesChanged(0)); + let _ = app.update(Message::IsolinesReleased); + assert!( + app.tabs[i].scene.meshes.contains_key(&stale), + "releasing without a change must rebuild nothing", + ); + } + + fn stale_mesh() -> crate::scene::model::mesh_model::MeshLodSet { + crate::scene::model::mesh_model::MeshLodSet { + lods: Vec::new(), + material: None, + face_materials: Default::default(), + visual_style: None, + complete: true, + edge_verts: Vec::new(), + edge_verts_low: Vec::new(), + curved_gens: Vec::new(), + metrics: Default::default(), + world_aabb: [0.0; 4], + z_aabb: [0.0; 2], + instance_source: None, + instance_transform: None, + instance_handle: None, + instance_color: None, + instance_aabb: None, + } + } } diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 6bb19741f..1be68627c 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -62,7 +62,7 @@ impl OpenCADStudio { return Some(Task::done(Message::ImagePick)); } - "REVCLOUD" => { + "REVCLOUD" | "REVCLOUD_RECTANGULAR" | "REVCLOUD_POLYGONAL" | "REVCLOUD_FREEHAND" => { use crate::modules::draw::draw::revcloud::RevCloudCommand; let view_height = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0; let default_arc_length = (view_height * 0.0125).max(1.0e-6); @@ -72,9 +72,12 @@ impl OpenCADStudio { .entities() .map(|entity| (entity.common().handle, entity.clone())) .collect(); - let cmd = RevCloudCommand::new(default_arc_length, sources); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); + let mut command = RevCloudCommand::new(default_arc_length, sources); + if let Some(mode) = cmd.strip_prefix("REVCLOUD_") { + command.on_text_input(mode); + } + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); } "ATTDEF" => { @@ -872,9 +875,13 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "SPLINE" => { + "SPLINE" | "SPLINECV" => { use crate::modules::draw::draw::spline::SplineCommand; - let new_cmd = SplineCommand::new(); + let new_cmd = if cmd == "SPLINECV" { + SplineCommand::control_vertices() + } else { + SplineCommand::new() + }; self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } @@ -1299,10 +1306,18 @@ impl OpenCADStudio { // REGION — convert selected closed boundaries (closed polylines / // circles) into Region entities (one wire loop each). "REGION" | "REG" => { + if self.tabs[i].scene.selected_entities().is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let command = SelectObjectsCommand::new("REGION"); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + return Some(iced::Task::none()); + } use acadrust::entities::Region; use acadrust::types::Vector3; let mut regions = Vec::new(); - for (_, e) in self.tabs[i].scene.selected_entities().iter() { + let mut sources = Vec::new(); + for (handle, e) in self.tabs[i].scene.selected_entities().iter() { let supported = matches!( e, acadrust::EntityType::LwPolyline(pl) @@ -1325,6 +1340,7 @@ impl OpenCADStudio { ); region.common.layer = self.tabs[i].active_layer.clone(); regions.push((region, body)); + sources.push(*handle); } } if regions.is_empty() { @@ -1343,6 +1359,10 @@ impl OpenCADStudio { } created.push(handle); } + if self.delete_objects != 0 { + self.tabs[i].scene.erase_entities(&sources); + self.refresh_properties(); + } self.tabs[i].dirty = true; self.command_line .push_output(crate::tf!("REGION: created {count} region(s).").as_ref()); @@ -1719,3 +1739,47 @@ impl OpenCADStudio { Some(self.finish_dispatch(cmd)) } } + +#[cfg(test)] +mod region_tests { + use crate::app::OpenCADStudio; + + #[test] + fn region_respects_delobj_and_preserves_unconverted_sources() { + for delete_sources in [false, true] { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 3"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#); + let i = app.active_tab; + let sources: Vec<_> = app.tabs[i].scene.document.entities() + .map(|entity| { + (entity.common().handle, matches!(entity, acadrust::EntityType::Circle(_))) + }) + .collect(); + assert_eq!(sources.len(), 2); + let handles: Vec<_> = sources.iter().map(|(handle, _)| *handle).collect(); + app.tabs[i].scene.select_entities(&handles); + app.delete_objects = i16::from(delete_sources); + + let _ = app.dispatch_command("REGION"); + + for (handle, is_circle) in &sources { + assert_eq!( + app.tabs[i].scene.document.get_entity(*handle).is_some(), + !delete_sources || !is_circle, + ); + } + let region_count = app.tabs[i].scene.document.entities() + .filter(|entity| matches!(entity, acadrust::EntityType::Region(_))) + .count(); + assert_eq!(region_count, 1); + + app.automation_op(r#"{"op":"undo"}"#); + for (handle, _) in &sources { + assert!(app.tabs[i].scene.document.get_entity(*handle).is_some()); + } + assert_eq!(app.tabs[i].scene.document.entities().count(), 2); + } + } +} diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 929807800..26e0a623d 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -75,6 +75,12 @@ impl OpenCADStudio { .dispatch_families(cmd, i) .unwrap_or_else(Task::none); } + // A new command abandons any grip edit and its numeric input. + let had_pending_grip_input = self.grip_pending.take().is_some(); + let had_active_grip = self.cancel_active_grip_edit(); + if had_pending_grip_input || had_active_grip { + self.command_line.input.clear(); + } // Starting a command closes any open ribbon dropdown (e.g. a style // combo left open) so it does not stay stuck behind the new tool. self.ribbon.close_dropdown(); @@ -553,6 +559,11 @@ inventory::submit!(crate::command::CommandRegistration { "GRIPHOT", "GRIPHOVER", "GRIPOBJLIMIT", + // Dispatched all along, but absent from the registry, so command-line + // completion never offered them. + "ISAVEBAK", + "SAVETIME", + "FILEASSOC", // Reset selected entities' overrides to follow their layer. "SETBYLAYER", // Remove duplicate objects; set drawing base point; audit integrity; @@ -702,7 +713,10 @@ inventory::submit!(crate::command::CommandRegistration { #[cfg(test)] mod marquee_cancel_tests { - use crate::app::OpenCADStudio; + use crate::app::{GripPendingValue, OpenCADStudio}; + use crate::scene::model::object::GripMenuAction; + use crate::scene::pick::grip::GripEdit; + use acadrust::Handle; use iced::time::Instant; fn fresh() -> OpenCADStudio { @@ -816,4 +830,33 @@ mod marquee_cancel_tests { assert!(sel.box_crossing); assert!(sel.box_crossing_locked); } + + #[test] + fn starting_a_command_cancels_a_grip_value_edit() { + let mut app = fresh(); + let i = app.active_tab; + let handle = Handle::new(42); + app.tabs[i].active_grip = Some(GripEdit::radius( + handle, + 1, + glam::DVec3::new(2.0, 0.0, 0.0), + )); + app.grip_pending = Some(GripPendingValue { + handle, + grip_id: 1, + action: GripMenuAction::Radius, + label: "New radius", + }); + app.command_line.input = "5".to_string(); + + let _ = app.dispatch_command("LINE"); + + assert!(app.tabs[i].active_grip.is_none()); + assert!(app.grip_pending.is_none()); + assert!(app.command_line.input.is_empty()); + assert_eq!( + app.tabs[i].active_cmd.as_deref().map(|cmd| cmd.name()), + Some("LINE") + ); + } } diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 939eb38fb..b99da19b4 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1159,27 +1159,45 @@ impl OpenCADStudio { "0" | "OFF" | "FALSE" => Some(false), _ => None, }; + let current_delete_objects = self.delete_objects; + let requested_delete_objects = (name == "DELOBJ") + .then(|| value.as_deref()?.parse::().ok()) + .flatten() + .filter(|value| (0..=3).contains(value)); let outcome: Result<(String, bool), String> = { let h = &mut self.tabs[i].scene.document.header; match name.as_str() { + // The standalone LTSCALE and CELTSCALE commands + // require a positive value; reaching the same + // variable through SETVAR used to accept zero or a + // negative one, which degenerates every dash + // pattern in the drawing. "LTSCALE" => match &value { Some(v) => v .parse::() + .ok() + .filter(|x| x.is_finite() && *x > 0.0) .map(|x| { h.linetype_scale = x; (format!("LTSCALE = {x}"), true) }) - .map_err(|_| "SETVAR: numeric value required.".into()), + .ok_or_else(|| { + "SETVAR: positive numeric value required.".into() + }), None => Ok((format!("LTSCALE = {}", h.linetype_scale), false)), }, "CELTSCALE" => match &value { Some(v) => v .parse::() + .ok() + .filter(|x| x.is_finite() && *x > 0.0) .map(|x| { h.current_entity_linetype_scale = x; (format!("CELTSCALE = {x}"), true) }) - .map_err(|_| "SETVAR: numeric value required.".into()), + .ok_or_else(|| { + "SETVAR: positive numeric value required.".into() + }), None => Ok(( format!("CELTSCALE = {}", h.current_entity_linetype_scale), false, @@ -1633,15 +1651,18 @@ impl OpenCADStudio { } }, "DELOBJ" => match &value { - Some(v) => parse_bool(v) - .map(|b| { - h.delete_objects = b; - (format!("DELOBJ = {}", b as i32), true) + Some(v) => v + .parse::() + .ok() + .filter(|value| (0..=3).contains(value)) + .map(|value| { + (format!("DELOBJ = {value}"), true) }) - .ok_or_else(|| "SETVAR: 0 or 1 required.".into()), - None => { - Ok((format!("DELOBJ = {}", h.delete_objects as i32), false)) - } + .ok_or_else(|| "SETVAR: integer from 0 to 3 required.".into()), + None => Ok(( + format!("DELOBJ = {current_delete_objects}"), + false, + )), }, "PLINEGEN" => match &value { Some(v) => parse_bool(v) @@ -2119,6 +2140,9 @@ impl OpenCADStudio { }; match outcome { Ok((msg, changed)) => { + if let Some(value) = requested_delete_objects { + self.delete_objects = value; + } if changed { if matches!( name.as_str(), @@ -2144,6 +2168,13 @@ impl OpenCADStudio { | "GRIPHOT" | "GRIPHOVER" | "GRIPOBJLIMIT" + | "DELOBJ" + // App-level, not part of the drawing: + // it lives in a process global and is + // snapshotted into the settings file. + // Without this it marked the drawing + // modified instead of persisting. + | "TEXTFILL" ) { self.persist_settings_if_changed(); } else { @@ -2172,9 +2203,7 @@ impl OpenCADStudio { // TEXTFILL reset the glyph atlas; re-tessellate so text picks // up the re-baked filled / hollow tiles. if name == "TEXTFILL" { - self.tabs[i] - .scene - .invalidate_text_geometry_dependencies(); + self.invalidate_text_everywhere(); } // LTSCALE scales the dash pattern baked into every wire, and // PDMODE / PDSIZE decide the point glyph built at tessellation @@ -3120,4 +3149,50 @@ mod tests { assert!(app.tabs[app.active_tab].active_cmd.is_none()); assert_eq!(app.commandline_fade_ms, 5000); } + + #[test] + fn delobj_accepts_values_zero_through_three() { + let mut app = fresh_app(); + + for value in 0..=3 { + let _ = app.run_command_line(&format!("SETVAR DELOBJ {value}")); + assert_eq!(app.delete_objects, value); + } + + let _ = app.run_command_line("SETVAR DELOBJ 4"); + assert_eq!(app.delete_objects, 3); + } } + +#[cfg(test)] +mod scale_validation_tests { + use crate::app::OpenCADStudio; + + /// A linetype scale of zero or less degenerates every dash pattern in the + /// drawing, which is why the standalone commands reject it. Reaching the + /// same variable through SETVAR used to accept it, so which door you came + /// through decided whether the drawing could be broken. + #[test] + fn both_doors_to_ltscale_refuse_a_non_positive_value() { + for name in ["LTSCALE", "CELTSCALE"] { + for entry in [format!("{name} -5"), format!("SETVAR {name} -5")] { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let before = if name == "LTSCALE" { + app.tabs[i].scene.document.header.linetype_scale + } else { + app.tabs[i].scene.document.header.current_entity_linetype_scale + }; + let _ = app.run_command_line(&entry); + let after = if name == "LTSCALE" { + app.tabs[i].scene.document.header.linetype_scale + } else { + app.tabs[i].scene.document.header.current_entity_linetype_scale + }; + assert_eq!(before, after, "{entry} must be refused"); + } + } + } +} + diff --git a/src/app/config.rs b/src/app/config.rs index e33e42b63..f6077621d 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -475,7 +475,16 @@ impl AppConfig { /// Persist the config as JSON. Best-effort; silent on unavailable or /// read-only storage. + /// + /// Does nothing under `cfg(test)`. The path is the developer's own + /// settings file, and the suite builds whole applications and changes + /// preferences on them — without this, running `cargo test` rewrites the + /// settings of whoever ran it. pub fn save(&self) { + if cfg!(test) { + return; + } + #[cfg(not(target_arch = "wasm32"))] { let Some(path) = config_path() else { return }; diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index d749e4a6c..4c27a8fa1 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -1,19 +1,75 @@ //! 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, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, - ObjectId, PlacementSpec, + 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. @@ -63,6 +119,10 @@ 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); @@ -104,16 +164,32 @@ impl DocApiBackend for HostSession<'_> { } 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) => PreparedDocEntity { - entity: convert::curve_spec_to_entity(spec)?, - solid: 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"); @@ -139,11 +215,38 @@ impl DocApiBackend for HostSession<'_> { } fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - let entity = convert::curve_spec_to_entity(spec)?; + 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 @@ -422,17 +525,19 @@ impl DocApiBackend for HostSession<'_> { ) -> ApiResult { use acadrust::entities::AttributeDefinition; use acadrust::types::Vector3; - let mut att = AttributeDefinition::default(); - att.tag = spec.tag.clone(); - att.prompt = spec.prompt.clone(); - att.default_value = spec.default_value.clone(); - att.insertion_point = Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ); - att.height = spec.height; - att.rotation = spec.rotation; + 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)); @@ -493,10 +598,12 @@ impl DocApiBackend for HostSession<'_> { if let Some(attr) = ins.attributes.iter_mut().find(|a| a.tag == tag) { attr.value = value.to_string(); } else { - let mut attr = acadrust::entities::AttributeEntity::default(); - attr.tag = tag.to_string(); - attr.value = value.to_string(); - attr.insertion_point = ins.insert_point; + 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)) { @@ -543,7 +650,16 @@ impl DocApiBackend for HostSession<'_> { ), None => ("Missing".to_string(), None), }; - out.push(EntityView { id, kind, bounds }); + 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) } @@ -602,16 +718,18 @@ impl DocApiBackend for HostSession<'_> { // 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 mut img = RasterImage::default(); - img.file_path = spec.file_path.clone(); - img.insertion_point = Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ); - img.u_vector = Vector3::new(spec.u_vector[0], spec.u_vector[1], spec.u_vector[2]); - img.v_vector = Vector3::new(spec.v_vector[0], spec.v_vector[1], spec.v_vector[2]); - img.size = Vector2::new(spec.size[0], spec.size[1]); + 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)) @@ -626,6 +744,353 @@ impl DocApiBackend for HostSession<'_> { 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 { @@ -746,6 +1211,7 @@ impl DocApiBackend for HostSession<'_> { Ok(EntityView { id, kind: convert::entity_kind_name(entity).to_string(), + layer: entity.common().layer.clone(), bounds: self.bounds(id).ok(), }) } @@ -831,16 +1297,15 @@ impl DocApiBackend for HostSession<'_> { // ── helpers ────────────────────────────────────────────────────────────────── +type SolidDisplay = ( + crate::scene::model::mesh_model::MeshLodSet, + Vec, + [f64; 3], +); + struct PreparedDocEntity { entity: EntityType, - solid: Option<( - KernelBody, - ( - crate::scene::model::mesh_model::MeshLodSet, - Vec, - [f64; 3], - ), - )>, + solid: Option<(KernelBody, SolidDisplay)>, } impl HostSession<'_> { @@ -999,13 +1464,235 @@ fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement { } } +// 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, SolidPrimitive}; - use ocs_doc_api::{DocApiEnvelope, HasId, ObjectId, Operation, Query, QueryResult, Receipt}; + 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(); @@ -1139,7 +1826,7 @@ mod tests { 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 { + 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, }); @@ -1179,7 +1866,7 @@ mod tests { 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 { + let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![ [0.0, 0.0, 0.0], [2.0, 0.0, 0.0], @@ -1217,7 +1904,7 @@ mod tests { 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 { + let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![ [1.0, 0.0, 0.0], [2.0, 0.0, 0.0], @@ -1259,7 +1946,7 @@ mod tests { 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 { + let mk_line = Operation::CreateCurve(Curve2Spec::Line { layer: None, start: [0.0; 3], end: [10.0; 3], }); @@ -1291,7 +1978,7 @@ mod tests { let circle = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [0.0; 3], radius: 2.0, })), @@ -1319,7 +2006,7 @@ mod tests { let arc = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { layer: None, centre: [5.0, 5.0, 0.0], radius: 4.0, start_angle: 0.0, @@ -1352,7 +2039,7 @@ mod tests { let ell = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { + 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, @@ -1377,7 +2064,7 @@ mod tests { let spline = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Spline { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Spline { layer: None, degree: 3, control_points: vec![ [0.0, 0.0, 0.0], @@ -1429,7 +2116,7 @@ mod tests { let ray = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { layer: None, origin: [1.0, 2.0, 0.0], direction: [1.0, 0.0, 0.0], })), @@ -1439,7 +2126,7 @@ mod tests { let xline = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::XLine { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::XLine { layer: None, origin: [0.0, 0.0, 0.0], direction: [0.0, 1.0, 0.0], })), @@ -1561,7 +2248,7 @@ mod tests { let circle = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [0.0, 0.0, 0.0], radius: 1.0, })), @@ -1609,7 +2296,7 @@ mod tests { let ray = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { layer: None, origin: [0.0, 0.0, 0.0], direction: [1.0, 0.0, 0.0], })), @@ -1637,7 +2324,7 @@ mod tests { let ell = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { layer: None, centre: [0.0; 3], major_axis: [4.0, 0.0, 0.0], ratio: 0.5, @@ -1668,7 +2355,7 @@ mod tests { let arc = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { layer: None, centre: [0.0; 3], radius: 2.0, start_angle: 0.0, @@ -1704,7 +2391,7 @@ mod tests { let a = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, position: [0.0; 3], })), ) @@ -1713,7 +2400,7 @@ mod tests { let b = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, position: [1.0; 3], })), ) @@ -1808,7 +2495,7 @@ mod tests { let line = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, start: [0.0; 3], end: [1.0; 3], })), @@ -1869,7 +2556,7 @@ mod tests { let c1 = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [0.0, 0.0, 0.0], radius: 5.0, })), @@ -1879,7 +2566,7 @@ mod tests { let c2 = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [0.0, 0.0, 10.0], radius: 2.0, })), @@ -1920,7 +2607,7 @@ mod tests { let poly = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { + 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, })), @@ -1967,7 +2654,7 @@ mod tests { 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 - 0.7071).abs() < 0.01, + (arc.radius - std::f64::consts::FRAC_1_SQRT_2).abs() < 0.01, "cw arc radius {}", arc.radius ); @@ -2286,8 +2973,8 @@ mod tests { 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 { position: [0.0; 3] }), - ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Ellipse { + 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, @@ -2314,7 +3001,7 @@ mod tests { let ell = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { layer: None, centre: [0.0; 3], major_axis: [4.0, 0.0, 0.0], ratio: 0.5, @@ -2392,24 +3079,28 @@ mod tests { 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 mut leader = acadrust::entities::Leader::default(); - 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), - ]; + 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 mut mesh = acadrust::entities::Mesh::default(); - 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), - ]; + 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)) @@ -2562,7 +3253,7 @@ mod tests { let line = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, start: [0.0; 3], end: [1.0; 3], })), @@ -2594,7 +3285,7 @@ mod tests { let line = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, start: [0.0; 3], end: [10.0; 3], })), @@ -2811,11 +3502,13 @@ mod tests { let img = { use acadrust::entities::RasterImage; use acadrust::types::{Vector2, Vector3}; - let mut img = RasterImage::default(); - img.insertion_point = Vector3::new(10.0, 20.0, 0.0); - img.u_vector = Vector3::new(0.5, 0.0, 0.0); // 0.5 world-units/pixel in X - img.v_vector = Vector3::new(0.0, 0.5, 0.0); - img.size = Vector2::new(100.0, 50.0); + 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() @@ -2927,7 +3620,7 @@ mod tests { 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 { + let env = DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, position: [0.0; 3], })); let bytes = bincode::serialize(&env).unwrap(); @@ -2967,7 +3660,7 @@ mod tests { let line = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, start: [0.0, 0.0, 0.0], end: [10.0, 0.0, 0.0], })), @@ -3000,7 +3693,7 @@ mod tests { let circle = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [5.0, 5.0, 0.0], radius: 3.0, })), @@ -3026,7 +3719,7 @@ mod tests { let point = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, position: [1.0, 2.0, 3.0], })), ) @@ -3046,7 +3739,7 @@ mod tests { let poly = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![ [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], @@ -3247,17 +3940,17 @@ mod tests { let mut host = HostSession::new(&mut app, 0); let invalid_ops = vec![ Operation::CreateMany(vec![ - EntitySpec::Curve(Curve2Spec::Point { position: [0.0; 3] }), + 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 { + Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [0.0; 3], radius: -1.0, }), - Operation::CreateCurve(Curve2Spec::Polyline { + Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![[0.0; 3], [1.0; 3]], closed: false, }), @@ -3290,7 +3983,7 @@ mod tests { let receipt = dispatch( &mut host, DocApiEnvelope::op(Operation::CreateMany(vec![ - EntitySpec::Curve(Curve2Spec::Point { position: [1.0; 3] }), + EntitySpec::Curve(Curve2Spec::Point { layer: None, position: [1.0; 3] }), EntitySpec::Solid(SolidPrimitive::Cuboid { origin: [0.0; 3], size: [2.0; 3], @@ -3360,7 +4053,7 @@ mod tests { let profile = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![ [0.0, 0.0, 7.0], [2.0, 0.0, 7.0], @@ -3473,4 +4166,336 @@ mod tests { .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/mod.rs b/src/app/mod.rs index f19b01c19..5d6a215e1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -347,6 +347,10 @@ pub(crate) enum TextEntryMode { FreeText, } +pub(crate) fn delobj_deletes_auxiliary(value: i16, creates_surface: bool) -> bool { + value == 2 || (value == 3 && !creates_surface) +} + pub(super) struct OpenCADStudio { start: Instant, control: control::State, @@ -513,6 +517,11 @@ pub(super) struct OpenCADStudio { crosshair_color: Option<[u8; 3]>, /// Editable Options buffer for the crosshair colour. crosshair_color_input: String, + /// Defer the ISOLINES mesh rebuild until the slider is released. + isolines_awaiting_regen: bool, + /// Edit buffer for the SNAPANG field on the Options Drafting page. Kept + /// separate from `snap_angle_deg` so a half-typed angle is not parsed. + snap_angle_input: String, /// Model-space lineweight preview scale, in percent (25..=200). lineweight_display_scale: i32, /// Whole-interface scale, in percent (50..=200) — drives iced's own @@ -592,6 +601,8 @@ pub(super) struct OpenCADStudio { /// `MIRRTEXT`) — the next command-line entry is its new value, empty keeps /// the current one. pending_setvar: Option, + /// DELOBJ system variable (0–3), shared by every open drawing. + delete_objects: i16, /// Cursor is hovering over the UCS icon body — drives the hover highlight. ucs_icon_hover: bool, /// UCS icon is selected (clicked): its grips are shown and draggable. @@ -2043,6 +2054,58 @@ pub enum Message { ShiftToAddToggled(bool), /// Toggle press-and-drag drawing a rectangle instead of a lasso (PICKDRAG). PickDragRectToggled(bool), + /// Change the automatic-save interval in minutes; 0 disables it (SAVETIME). + SaveTimeChanged(i32), + /// Toggle keeping a `.bak` copy when overwriting a drawing (ISAVEBAK). + BackupOnSaveChanged(bool), + /// Toggle filled TrueType glyphs (TEXTFILL). + TextFillChanged(bool), + /// Change how many prompt lines sit above the command window (CLIPROMPTLINES). + ClipromptLinesChanged(i32), + /// Change how long command-line history lines stay visible (COMMANDLINEFADETIME). + CommandLineFadeChanged(i32), + /// Toggle reversing the mouse-wheel zoom direction (ZOOMWHEEL). + ZoomWheelReversedChanged(bool), + /// Change how far one wheel notch zooms (ZOOMFACTOR, 3..=100). + ZoomFactorChanged(i32), + /// Toggle TEXTEDIT ending after one object (TEXTEDITMODE). + TextEditModeChanged(bool), + /// Toggle continued dimensions inheriting the base style (DIMCONTINUEMODE). + DimContinueModeChanged(bool), + /// Change which points QDIM measures from (0 endpoints, 1 intersections). + QdimSnapPriorityChanged(u8), + /// Change which annotative objects pick up a new scale (ANNOAUTOSCALE). + AnnoAutoScaleChanged(i8), + /// Edit the drafting rotation field; parsed when it holds a valid angle (SNAPANG). + SnapAngleInputChanged(String), + /// Change the polar tracking increment in degrees. + PolarIncrementChanged(f32), + /// Toggle the navigation cube (NAVVCUBE). + ShowViewCubeChanged(bool), + /// Toggle the UCS icon (UCSICON). + ShowUcsIconChanged(bool), + /// Toggle drawing the UCS icon at the origin (UCSICON ORigin). + UcsIconAtOriginChanged(bool), + /// Toggle selection cycling from Options; the status-bar pill toggles the same flag. + SelectionCyclingChanged(bool), + /// Reveal one of the application's own folders in the system file manager. + OpenFolder(String), + /// Change isolines per surface in the current drawing (ISOLINES). + IsolinesChanged(i16), + /// The isolines slider was released; rebuild the meshes if it moved. + IsolinesReleased, + /// Toggle silhouette edges in the current drawing (DISPSILH). + DispSilhChanged(bool), + /// Change surface density U in the current drawing (SURFU). + SurfaceUChanged(i16), + /// Change surface density V in the current drawing (SURFV). + SurfaceVChanged(i16), + /// Change the surface type in the current drawing (SURFTYPE). + SurfaceTypeChanged(i16), + /// Toggle recording composite-solid history in the current drawing (SOLIDHIST). + SolidHistChanged(bool), + /// Change when solid history is shown in the current drawing (SHOWHIST). + ShowHistChanged(i16), /// Restore Model Space display/canvas appearance to defaults. RestoreModelSpaceDisplayDefaults, /// Restore Selection visual effect settings to defaults. @@ -3500,6 +3563,8 @@ impl OpenCADStudio { cursor_type: settings::CursorType::Crosshair, crosshair_color: None, crosshair_color_input: String::new(), + isolines_awaiting_regen: false, + snap_angle_input: "0".to_string(), lineweight_display_scale: 100, ui_scale: 100, isometric_drafting: false, @@ -3526,6 +3591,7 @@ impl OpenCADStudio { block_usage_last_persist: None, awaiting_vports: false, pending_setvar: None, + delete_objects: 1, ucs_icon_hover: false, ucs_icon_selected: false, ucs_grip_drag: None, @@ -3898,7 +3964,16 @@ impl OpenCADStudio { #[cfg(test)] pub(crate) fn new_for_test() -> Self { - Self::new() + let mut app = Self::new(); + // `new` loads the real settings file, so without this every test runs + // against whatever the developer last set in the application — a suite + // that passes on a clean machine and fails on a used one. It surfaced + // when a persisted `GRIPOBJLIMIT` made the grip-limit test see 32767 + // where it expected the default, and the number of persisted settings + // only grows. + app.apply_config(crate::app::config::AppConfig::default()); + app.last_saved_config = Some(app.current_config()); + app } /// Install `cmd` as the active interactive command for tab `tab`. diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index d767d9005..3d8b57541 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -402,6 +402,24 @@ impl super::OpenCADStudio { self.add_surface_model_inner(entity, surface, true) } + pub(super) fn add_surface_model_with_history( + &mut self, + entity: EntityType, + surface: Body, + history: SolidHistoryOperation, + ) -> Handle { + let i = self.active_tab; + let handle = self.add_surface_model_inner(entity, surface, true); + if handle.is_null() { + return handle; + } + if !self.tabs[i].scene.create_solid_history(handle, history) { + self.tabs[i].scene.rollback_new_entities(&[handle]); + return Handle::NULL; + } + handle + } + fn add_surface_model_preserving_style( &mut self, entity: EntityType, @@ -2293,7 +2311,9 @@ impl super::OpenCADStudio { surfaces.push(surf); } self.push_undo_snapshot(i, "CONVTOSURFACE"); - self.tabs[i].scene.erase_entities(&handles); + if self.delete_objects != 0 { + self.tabs[i].scene.erase_entities(&handles); + } let n = surfaces.len(); for surf in surfaces { self.tabs[i].scene.add_entity(EntityType::Surface(surf)); diff --git a/src/app/properties.rs b/src/app/properties.rs index 47501b87a..c05ee3bc2 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2221,6 +2221,17 @@ impl OpenCADStudio { entity_type_label(entity) } } + acadrust::EntityType::Surface(_) + if matches!( + crate::scene::model::solid_history::primitive_property_operation( + &self.tabs[i].scene.document, + handle, + ), + Some(acadrust::objects::SolidHistoryOperation::Extrusion(_)) + ) => + { + format!("{} ({})", t!("Surface"), t!("Extrusion")) + } _ => entity_type_label(entity), }; ui::PropertiesPanel { @@ -2328,23 +2339,22 @@ filter={:.1} local={:.1} aggregate={:.1} entities={}", ); } } - if local_refs.iter().all(|(handle, _)| { + let compact_solids = local_refs.iter().all(|(handle, _)| { crate::scene::model::solid_history::has_compact_solid_properties( &self.tabs[i].scene.document, *handle, ) - }) { - sections.retain(|section| { - !section.props.iter().any(|property| { - property.field.starts_with("acis_") - || property.field.starts_with("s3d_") - }) - }); + }); + if compact_solids { + retain_compact_solid_sections(&mut sections); } sections.extend(aggregate_solid_history_sections( &self.tabs[i].scene.document, &local_refs.iter().map(|(handle, _)| *handle).collect::>(), )); + if compact_solids { + retain_compact_solid_sections(&mut sections); + } ui::PropertiesPanel { choice_combos: sections .iter() @@ -3293,6 +3303,10 @@ fn retain_compact_solid_sections( sections: &mut Vec, ) { sections.iter_mut().for_each(|section| { + let internal_surface_section = section + .props + .iter() + .any(|property| property.field == "srf_kind"); section.props.retain(|property| { matches!( property.field, @@ -3305,7 +3319,8 @@ fn retain_compact_solid_sections( | "transparency" | "hyperlink" | "material" - ) || crate::scene::model::solid_history::is_specialized_property(property.field) + ) || (!internal_surface_section + && crate::scene::model::solid_history::is_specialized_property(property.field)) }); }); sections.retain(|section| !section.props.is_empty()); @@ -4237,6 +4252,29 @@ mod grip_limit_tests { ); } + /// The ViewCube, the UCS icon and selection cycling all have commands and + /// status-bar pills, but their values lived only on the app struct: turn + /// the ViewCube off, restart, and it came straight back. Nothing failed — + /// the setting simply evaporated — which is exactly the kind of thing a + /// round-trip test catches and a person does not. + #[test] + fn the_view_toggles_survive_a_save_and_load() { + let mut app = OpenCADStudio::new_for_test(); + app.show_viewcube = false; + app.show_ucs_icon = false; + app.ucs_icon_at_origin = false; + app.selection_cycling = true; + + let saved = app.current_settings(); + let mut restored = OpenCADStudio::new_for_test(); + restored.apply_settings(&saved); + + assert!(!restored.show_viewcube, "the ViewCube must stay off"); + assert!(!restored.show_ucs_icon); + assert!(!restored.ucs_icon_at_origin); + assert!(restored.selection_cycling); + } + /// The Options card writes these four straight onto the app and relies on /// the snapshot/restore pair to carry them across a restart. Three of them /// had no UI until now and so no reason for anyone to notice if the pair diff --git a/src/app/settings.rs b/src/app/settings.rs index 1f8fa5d5e..5c5a47c6d 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -145,6 +145,39 @@ pub(crate) fn snaps_from_osmode(osmode: i32) -> (Vec, bool) { (modes, osmode & OSMODE_SUPPRESS == 0) } +fn deserialize_options_tab<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + use crate::ui::window::options::OptionsTab; + use serde::Deserialize; + let name = String::deserialize(deserializer).unwrap_or_default(); + Ok(match name.as_str() { + "files" => OptionsTab::Files, + "open-and-save" => OptionsTab::OpenAndSave, + "display" => OptionsTab::Display, + "drafting" => OptionsTab::Drafting, + "modeling" => OptionsTab::Modeling, + "selection" => OptionsTab::Selection, + "user-preferences" => OptionsTab::UserPreferences, + _ => OptionsTab::General, + }) +} + +/// Render a drafting angle without a trailing `.0`, so `22.5` but `30`. +/// +/// The polar pop-up formats its presets the same way; both are showing the +/// same kind of number to the same person. +pub fn format_snap_angle(deg: f32) -> String { + if (deg - deg.round()).abs() < 1e-4 { + format!("{}", deg.round() as i32) + } else { + format!("{deg}") + } +} + /// GRIPOBJLIMIT default: past this many selected objects, no grips are drawn. pub const DEFAULT_GRIP_OBJECT_LIMIT: i32 = 100; @@ -170,6 +203,18 @@ 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, + /// 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, + /// Show the navigation cube (NAVVCUBE). + pub show_viewcube: bool, + /// Show the UCS icon (UCSICON). + pub show_ucs_icon: bool, + /// Draw the UCS icon at the origin rather than in the corner + /// (UCSICON ORigin / NOorigin). + pub ucs_icon_at_origin: bool, + /// Selection cycling: a click where objects overlap opens a picker. + pub selection_cycling: bool, /// CURSORTYPE: crosshair or the platform pointer over the drawing. pub cursor_type: CursorType, /// Explicit crosshair RGB. `None` keeps automatic background contrast. @@ -227,6 +272,10 @@ pub struct UserSettings { /// the current creation layer/style. Registry-style, app-level preference. #[serde(default = "default_dimension_continue_mode")] pub dimension_continue_mode: i16, + /// DELOBJ: source-geometry deletion policy (0–3). Registry-style, + /// app-level preference; first-run default is 1. + #[serde(default = "default_delete_objects")] + pub delete_objects: i16, /// TEXTFILL: fill TrueType glyphs (true) or draw them hollow (false). pub textfill: bool, /// When true, saving over an existing file first copies it to a sibling @@ -265,12 +314,6 @@ pub struct UserSettings { pub pick_drag_rect: bool, /// Show the floating Quick Properties panel when objects are selected. pub quick_properties: bool, - /// Persisted viewport background colours (0–255 RGB); `None` = app default - /// (dark grey model / off-white paper). Applied to every drawing tab on - /// launch and to tabs opened later, so a chosen background survives restarts - /// (#188). - pub bg_color: Option<[u8; 3]>, - pub paper_bg_color: Option<[u8; 3]>, /// Interface language preference. `System` negotiates against the /// platform locale on every launch. pub language: crate::i18n::Language, @@ -298,6 +341,10 @@ fn default_clipromptlines() -> i32 { 3 } +fn default_delete_objects() -> i16 { + 1 +} + fn default_commandline_fade_ms() -> i32 { 3000 } @@ -344,6 +391,11 @@ impl Default for UserSettings { zoom_factor: 60, cursor_size: 5, pick_box: 3, + options_tab: crate::ui::window::options::OptionsTab::General, + show_viewcube: true, + show_ucs_icon: true, + ucs_icon_at_origin: true, + selection_cycling: false, double_click_block_refedit: false, double_click_block_attedit: true, grip_object_limit: DEFAULT_GRIP_OBJECT_LIMIT, @@ -367,6 +419,7 @@ impl Default for UserSettings { texteditmode: false, quick_dimension_snap_priority: 0, dimension_continue_mode: 1, + delete_objects: 1, textfill: true, backup_on_save: true, file_assoc_enabled: true, @@ -377,8 +430,6 @@ impl Default for UserSettings { pick_add: true, pick_drag_rect: false, quick_properties: false, - bg_color: None, - paper_bg_color: None, language: crate::i18n::Language::default(), cliprompt_lines: 3, commandline_fade_ms: 3000, @@ -428,4 +479,23 @@ mod tests { let b: std::collections::HashSet<_> = back.into_iter().collect(); assert_eq!(a, b); } + + #[test] + fn a_page_name_that_no_longer_exists_does_not_cost_the_other_settings() { + let json = r#"{ + "settings": { + "options_tab": "drawing", + "pick_add": false, + "savetime_min": 42 + } + }"#; + let cfg: crate::app::config::AppConfig = + serde_json::from_str(json).expect("an unknown page name must not fail the parse"); + assert_eq!( + cfg.settings.options_tab, + crate::ui::window::options::OptionsTab::General, + ); + assert!(!cfg.settings.pick_add, "the rest of the file must survive"); + assert_eq!(cfg.settings.savetime_min, 42); + } } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index c91e0f3e6..23df084a7 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -191,7 +191,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | '*' | '/' | '^' | '%' | '(' | ')') }); - if dyn_field_char && self.dyn_input && !self.tabs[i].dyn_fields.is_empty() { + if dyn_field_char + && self.command_line.input.is_empty() + && self.dyn_input + && !self.tabs[i].dyn_fields.is_empty() + { let a = self.tabs[i] .dyn_active .min(self.tabs[i].dyn_fields.len() - 1); @@ -199,6 +203,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .buffer .get_or_insert_with(String::new) .push_str(&s); + if self.tabs[i].active_grip.as_ref().is_some_and(|grip| { + matches!( + grip.mode, + GripEditMode::Lengthen + | GripEditMode::Radius + | GripEditMode::ArcLength + ) + }) { + self.command_line.input.push_str(&s); + } } else { // Command-line entry is shown uppercase — except in // free-form text prompts, where the typed case is the @@ -242,6 +256,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if buf.is_empty() { self.tabs[i].dyn_fields[a].buffer = None; } + if self.tabs[i].active_grip.as_ref().is_some_and(|grip| { + matches!( + grip.mode, + GripEditMode::Lengthen + | GripEditMode::Radius + | GripEditMode::ArcLength + ) + }) { + self.command_line.input.pop(); + } return self.focus_cmd_input(); } } @@ -277,13 +301,49 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } // Grip-menu value prompt — consume the typed number and // route it through `apply_grip_menu_value`. + // Interactive grip prompts are valid only while their matching + // grip edit is alive. Do not let a stale Radius / Lengthen / + // Arc Length prompt consume Enter or coordinates from a later + // drawing command. + let stale_interactive_grip_prompt = self.grip_pending.as_ref().is_some_and(|pending| { + let expected_mode = match pending.action { + crate::scene::model::object::GripMenuAction::Lengthen => { + Some(GripEditMode::Lengthen) + } + crate::scene::model::object::GripMenuAction::Radius => { + Some(GripEditMode::Radius) + } + crate::scene::model::object::GripMenuAction::ArcLength => { + Some(GripEditMode::ArcLength) + } + _ => None, + }; + expected_mode.is_some_and(|mode| { + !self.tabs[self.active_tab].active_grip.as_ref().is_some_and(|grip| { + grip.mode == mode + && grip.handle == pending.handle + && grip.grip_id == pending.grip_id + }) + }) + }); + if stale_interactive_grip_prompt { + self.grip_pending = None; + } if let Some(pending) = self.grip_pending.take() { let i = self.active_tab; if self.reject_locked_edit(i, pending.handle) { self.cancel_active_grip_edit(); return Task::none(); } - let raw = crate::app::expr_eval::eval_to_string(self.command_line.input.trim()); + // Dynamic Input keeps numeric typing in its focused field. + // Fall back to the command-line buffer so the established + // prompt workflow remains unchanged when DYN is disabled. + let dyn_value = self.tabs[i] + .dyn_fields + .iter() + .find_map(|field| field.buffer.as_deref()); + let entered = dyn_value.unwrap_or(self.command_line.input.trim()); + let raw = crate::app::expr_eval::eval_to_string(entered); self.command_line.input.clear(); let Ok(v) = raw.parse::() else { self.command_line.push_error(crate::tf!( @@ -293,15 +353,27 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.grip_pending = Some(pending); return self.focus_cmd_input(); }; - let interactive_lengthen = self.tabs[i] + let interactive_value_grip = self.tabs[i] .active_grip .as_ref() .is_some_and(|grip| { - grip.mode == GripEditMode::Lengthen + matches!( + (grip.mode, pending.action), + ( + GripEditMode::Lengthen, + crate::scene::model::object::GripMenuAction::Lengthen, + ) | ( + GripEditMode::Radius, + crate::scene::model::object::GripMenuAction::Radius, + ) | ( + GripEditMode::ArcLength, + crate::scene::model::object::GripMenuAction::ArcLength, + ) + ) && grip.handle == pending.handle && grip.grip_id == pending.grip_id }); - if interactive_lengthen { + if interactive_value_grip { self.cancel_active_grip_edit(); } use crate::entities::traits::EntityTypeOps; @@ -566,6 +638,30 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .map(|c| c.is_free_text_step()) .unwrap_or(false); let raw = self.command_line.input.trim().to_string(); + let is_mtp = raw.trim_start_matches('_').eq_ignore_ascii_case("MTP") + || raw.trim_start_matches('_').eq_ignore_ascii_case("M2P"); + if is_mtp { + let is_point_step = !self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.input_kind().wants_text()) + .unwrap_or(true) + || self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.point_step_accepts_keywords()) + .unwrap_or(false); + let not_entity_pick = !self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.needs_entity_pick()) + .unwrap_or(false); + if is_point_step && not_entity_pick { + self.command_line.input.clear(); + self.start_mtp_modifier(i); + return self.focus_cmd_input(); + } + } let text = if free_text { raw } else { @@ -1306,7 +1402,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { action: item.action, label, }); - if matches!(item.action, GripMenuAction::Lengthen) { + if matches!( + item.action, + GripMenuAction::Lengthen + | GripMenuAction::Radius + | GripMenuAction::ArcLength + ) { if let Some((_, grip)) = self.tabs[i] .selected_grip_handles .iter() @@ -1315,14 +1416,52 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { **owner == popup.handle && grip.id == popup.grip_id }) { - self.tabs[i].active_grip = Some(GripEdit::lengthen( - popup.handle, - popup.grip_id, - grip.world, - )); + if self.grip_originals.is_empty() { + self.grip_originals = self.tabs[i] + .scene + .document + .get_entity(popup.handle) + .cloned() + .map(|entity| vec![(popup.handle, entity)]) + .unwrap_or_default(); + } + self.tabs[i].active_grip = Some(match item.action { + GripMenuAction::Radius => GripEdit::radius( + popup.handle, + popup.grip_id, + grip.world, + ), + GripMenuAction::ArcLength => GripEdit::arc_length( + popup.handle, + popup.grip_id, + grip.world, + ), + _ => GripEdit::lengthen( + popup.handle, + popup.grip_id, + grip.world, + ), + }); + } + // Popup actions do not pass through the normal grip + // press handler, which is where dynamic fields are + // usually seeded. Build the value field + // immediately so it is visible before the next mouse + // move (and so keyboard input has a field to target). + self.sync_dyn_fields(); + if matches!(item.action, GripMenuAction::Radius) { + self.command_line.push_info( + crate::t!("Specify point or enter radius:").as_ref(), + ); + } else if matches!(item.action, GripMenuAction::ArcLength) { + self.command_line.push_info( + crate::t!("Specify point or enter arc length:").as_ref(), + ); + } else { + self.command_line.push_info( + crate::t!("Specify point or enter distance:").as_ref(), + ); } - self.command_line - .push_info(crate::t!("Specify point or enter distance:").as_ref()); } else { self.command_line.push_info(crate::tf!("{label}:").as_ref()); } @@ -1443,24 +1582,25 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } // One-shot action — apply immediately. - let unchanged = self.tabs[i] - .scene - .document - .get_entity(popup.handle) - .is_some_and(|entity| match (item.action, entity) { - (GripMenuAction::ShowFit, acadrust::EntityType::Spline(spline)) => { - !spline.cv_frame_visible - && crate::entities::spline::uses_fit_method(spline) - } - ( - GripMenuAction::ShowControlVertices, - acadrust::EntityType::Spline(spline), - ) => { - spline.cv_frame_visible - || !crate::entities::spline::uses_fit_method(spline) - } - _ => false, - }); + let unchanged = item.label.starts_with('✓') + || self.tabs[i] + .scene + .document + .get_entity(popup.handle) + .is_some_and(|entity| match (item.action, entity) { + (GripMenuAction::ShowFit, acadrust::EntityType::Spline(spline)) => { + !spline.cv_frame_visible + && crate::entities::spline::uses_fit_method(spline) + } + ( + GripMenuAction::ShowControlVertices, + acadrust::EntityType::Spline(spline), + ) => { + spline.cv_frame_visible + || !crate::entities::spline::uses_fit_method(spline) + } + _ => false, + }); if unchanged { return Task::none(); } @@ -2002,7 +2142,51 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } self.push_undo_snapshot(i, "CHPROP"); - if crate::scene::model::solid_history::is_loft_geometry_choice(field) { + if crate::scene::model::solid_history::is_surface_property_choice(field) { + use crate::scene::model::solid_history::{ + PROP_SURFACE_MAINTAIN_ASSOCIATIVITY, PROP_SURFACE_SHOW_ASSOCIATIVITY, + PROP_SURFACE_WIREFRAME_TYPE, + }; + for &handle in &handles { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } + let Some(mut state) = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| match entity { + acadrust::EntityType::Surface(surface) => Some( + crate::entities::solid3d::surface_property_state(surface), + ), + _ => None, + }) + else { + continue; + }; + match field { + PROP_SURFACE_WIREFRAME_TYPE => { + state.isolines = !value.eq_ignore_ascii_case("Isoparms") + } + PROP_SURFACE_MAINTAIN_ASSOCIATIVITY => { + state.maintain_associativity = value.eq_ignore_ascii_case("Yes") + } + PROP_SURFACE_SHOW_ASSOCIATIVITY => { + state.show_associativity = value.eq_ignore_ascii_case("Yes") + } + _ => continue, + } + crate::scene::view::dispatch::set_entity_xdata( + &mut self.tabs[i].scene.document, + handle, + crate::entities::solid3d::SURFACE_PROPERTIES_APP, + Some(crate::entities::solid3d::surface_property_xdata_values(state)), + ); + if field == PROP_SURFACE_WIREFRAME_TYPE { + self.tabs[i].scene.reseed_derived_caches(handle); + } + } + } else if crate::scene::model::solid_history::is_loft_geometry_choice(field) { // These choices change the generated body, not just history // flags. Use the same transactional rebuild as numeric edits. for &handle in &handles { @@ -2980,6 +3164,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } + if matches!(field, "srf_u_isolines" | "srf_v_isolines") + { + self.tabs[i].scene.reseed_derived_caches(handle); + } } } } diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index 1e1888055..bfe201e6a 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -65,34 +65,55 @@ impl OpenCADStudio { // A normal 2D grip stretch behaves like a point-placement step: // distance and angle are measured from the grip's original position. + // Lengthen uses the same overlay machinery, but it only needs the + // distance field: the entity itself determines the endpoint direction + // (or arc sweep), so an angle entry would be misleading. // // Grip editing is not an `active_cmd`, so handle it before the normal // command-only path below. - let grip_origin = self.tabs[i] + let grip_input = self.tabs[i] .active_grip .as_ref() - .filter(|grip| { - grip.mode == crate::scene::pick::grip::GripEditMode::Stretch - }) - .map(|grip| grip.origin_world); + .map(|grip| match grip.mode { + crate::scene::pick::grip::GripEditMode::Stretch => { + (grip.origin_world, None) + } + crate::scene::pick::grip::GripEditMode::Lengthen => { + (grip.origin_world, Some(crate::command::DynRole::Distance)) + } + crate::scene::pick::grip::GripEditMode::Radius => { + (grip.origin_world, Some(crate::command::DynRole::Radius)) + } + crate::scene::pick::grip::GripEditMode::ArcLength => { + (grip.origin_world, Some(crate::command::DynRole::Distance)) + } + }); - if let Some(origin) = grip_origin { - let wanted = [DynComponent::Distance, DynComponent::Angle]; - let current: Vec = self.tabs[i] + if let Some((origin, scalar_role)) = grip_input { + let wanted_roles: Vec = scalar_role.map_or_else( + || vec![crate::command::DynRole::Distance, crate::command::DynRole::Angle], + |role| vec![role], + ); + let current: Vec = self.tabs[i] .dyn_fields .iter() - .map(|field| field.component) + .map(|field| field.role) .collect(); - if current.as_slice() != wanted { - self.tabs[i].dyn_fields = wanted - .into_iter() - .map(DynFieldEntry::new) + if current != wanted_roles { + self.tabs[i].dyn_fields = wanted_roles + .iter() + .copied() + .map(DynFieldEntry::from_role) .collect(); self.tabs[i].dyn_active = 0; } - self.tabs[i].dyn_guide = crate::command::DynGuide::Polar; + self.tabs[i].dyn_guide = if scalar_role.is_some() { + crate::command::DynGuide::Radius + } else { + crate::command::DynGuide::Polar + }; self.tabs[i].dyn_anchor = Some(origin); self.tabs[i].dyn_ref = None; return; diff --git a/src/app/update/file.rs b/src/app/update/file.rs index b3465f2e3..185fab031 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -355,6 +355,37 @@ impl OpenCADStudio { } } + /// Write a header variable on the active drawing and mark it modified. + pub(in crate::app) fn set_drawing_var( + &mut self, + write: impl FnOnce(&mut acadrust::document::HeaderVariables), + ) { + let i = self.active_tab; + if let Some(tab) = self.tabs.get_mut(i) { + write(&mut tab.scene.document.header); + tab.dirty = true; + } + } + + /// Re-tessellate active-drawing solids after an ISOLINES change. + pub(in crate::app) fn regenerate_meshes(&mut self) { + let i = self.active_tab; + if let Some(tab) = self.tabs.get_mut(i) { + tab.scene.populate_meshes_from_document(); + } + } + + /// Rebuild text geometry in every open drawing. + /// + /// `TEXTFILL` is a process-global, so a change to it invalidates the text + /// in every tab. Doing only the active one leaves the others rendering the + /// previous setting until some unrelated edit happens to rebuild them. + pub(in crate::app) fn invalidate_text_everywhere(&mut self) { + for tab in &mut self.tabs { + tab.scene.invalidate_text_geometry_dependencies(); + } + } + /// Snapshot the persisted UI preferences from live state. pub(in crate::app) fn current_settings(&self) -> crate::app::settings::UserSettings { crate::app::settings::UserSettings { @@ -365,6 +396,11 @@ impl OpenCADStudio { zoom_factor: self.zoom_factor, cursor_size: self.cursor_size, pick_box: self.pick_box, + options_tab: self.options_tab, + show_viewcube: self.show_viewcube, + show_ucs_icon: self.show_ucs_icon, + ucs_icon_at_origin: self.ucs_icon_at_origin, + selection_cycling: self.selection_cycling, 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, @@ -393,6 +429,7 @@ impl OpenCADStudio { texteditmode: self.texteditmode, quick_dimension_snap_priority: self.quick_dimension_snap_priority, dimension_continue_mode: self.dimension_continue_mode, + delete_objects: self.delete_objects, textfill: crate::scene::text::sdf_atlas::textfill(), backup_on_save: self.backup_on_save, file_assoc_enabled: self.file_assoc_enabled, @@ -403,8 +440,6 @@ impl OpenCADStudio { pick_add: self.pick_add, pick_drag_rect: self.pick_drag_rect, quick_properties: self.quick_properties, - bg_color: None, - paper_bg_color: None, language: self.language, cliprompt_lines: crate::app::settings::clamp_clipromptlines(self.cliprompt_lines), commandline_fade_ms: crate::app::settings::clamp_commandline_fade_ms( @@ -424,6 +459,14 @@ impl OpenCADStudio { self.zoom_factor = s.zoom_factor.clamp(3, 100); self.cursor_size = s.cursor_size.clamp(1, 100); self.pick_box = s.pick_box.clamp(0, 50); + self.options_tab = s.options_tab; + // These four drive real features with commands and status-bar pills, + // but they lived only on the app struct: turning the ViewCube off and + // restarting brought it straight back. + self.show_viewcube = s.show_viewcube; + self.show_ucs_icon = s.show_ucs_icon; + self.ucs_icon_at_origin = s.ucs_icon_at_origin; + self.selection_cycling = s.selection_cycling; 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); @@ -442,6 +485,9 @@ impl OpenCADStudio { } else { 0.0 }; + // The Options field edits a buffer rather than the value, so it has to + // be reseeded whenever the value is restored from behind it. + self.snap_angle_input = crate::app::settings::format_snap_angle(self.snap_angle_deg); // Ortho + running OSNAP are per-drawing (adopted from the header on // open / tab switch), not app-global, so they are not applied here. self.snapper.otrack_enabled = s.otrack; @@ -464,6 +510,7 @@ impl OpenCADStudio { self.texteditmode = s.texteditmode; self.quick_dimension_snap_priority = s.quick_dimension_snap_priority.min(1); self.dimension_continue_mode = s.dimension_continue_mode.clamp(0, 1); + self.delete_objects = s.delete_objects.clamp(0, 3); 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; @@ -475,7 +522,6 @@ impl OpenCADStudio { self.pick_add = s.pick_add; self.pick_drag_rect = s.pick_drag_rect; self.quick_properties = s.quick_properties; - // Legacy settings.bg_color / paper_bg_color are superseded by model_space config. if crate::i18n::set_language(s.language).is_ok() { self.language = s.language; } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 0436a531f..6357e45d1 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1583,6 +1583,24 @@ impl OpenCADStudio { } let live_input = s.clone(); self.command_line.input = live_input.clone(); + let i = self.active_tab; + if self.dyn_input + && self.tabs[i].active_grip.as_ref().is_some_and(|grip| { + matches!( + grip.mode, + crate::scene::pick::grip::GripEditMode::Lengthen + | crate::scene::pick::grip::GripEditMode::Radius + | crate::scene::pick::grip::GripEditMode::ArcLength + ) + }) + && !self.tabs[i].dyn_fields.is_empty() + { + let a = self.tabs[i] + .dyn_active + .min(self.tabs[i].dyn_fields.len() - 1); + self.tabs[i].dyn_fields[a].buffer = + (!live_input.is_empty()).then_some(live_input.clone()); + } self.command_line.autocomplete_cursor = None; self.command_line.cancel_history_navigation(); // Live incremental search for INSERT/MINSERT: update picker on each keystroke @@ -6269,6 +6287,188 @@ impl OpenCADStudio { Task::none() } + Message::SaveTimeChanged(minutes) => { + self.savetime_min = minutes.max(0); + self.persist_settings_if_changed(); + Task::none() + } + + Message::BackupOnSaveChanged(enabled) => { + self.backup_on_save = enabled; + self.persist_settings_if_changed(); + Task::none() + } + + Message::TextFillChanged(filled) => { + crate::scene::text::sdf_atlas::set_textfill(filled); + self.invalidate_text_everywhere(); + self.persist_settings_if_changed(); + Task::none() + } + + Message::ClipromptLinesChanged(lines) => { + let lines = crate::app::settings::clamp_clipromptlines(lines); + self.cliprompt_lines = lines; + self.command_line.set_cliprompt_lines(lines.clamp(0, 50) as u8); + self.persist_settings_if_changed(); + Task::none() + } + + Message::CommandLineFadeChanged(ms) => { + let ms = crate::app::settings::clamp_commandline_fade_ms(ms); + self.commandline_fade_ms = ms; + self.command_line.set_commandline_fade_ms(ms.max(0) as u32); + self.persist_settings_if_changed(); + Task::none() + } + + Message::ZoomWheelReversedChanged(reversed) => { + self.zoom_wheel_reversed = reversed; + self.persist_settings_if_changed(); + Task::none() + } + + Message::ZoomFactorChanged(factor) => { + self.zoom_factor = factor.clamp(3, 100); + self.persist_settings_if_changed(); + Task::none() + } + + Message::TextEditModeChanged(single) => { + self.texteditmode = single; + self.persist_settings_if_changed(); + Task::none() + } + + Message::DimContinueModeChanged(inherit) => { + self.dimension_continue_mode = i16::from(inherit); + self.persist_settings_if_changed(); + Task::none() + } + + Message::QdimSnapPriorityChanged(priority) => { + self.quick_dimension_snap_priority = priority.min(1); + self.persist_settings_if_changed(); + Task::none() + } + + // The sign carries on/off and the magnitude the mode, so switching + // off and back on has to return to the mode that was chosen — the + // same convention the status-bar pill uses when it negates. + Message::AnnoAutoScaleChanged(mode) => { + self.annotation_auto_scale = if mode == 0 { + -self.annotation_auto_scale.abs().max(1) + } else { + mode.clamp(1, 4) + }; + self.persist_settings_if_changed(); + Task::none() + } + + // Typed into freely; only committed when it parses, so clearing + // the field to retype does not snap the crosshair back to zero. + Message::SnapAngleInputChanged(value) => { + self.snap_angle_input = value; + if let Ok(angle) = self.snap_angle_input.trim().parse::() { + if angle.is_finite() { + self.snap_angle_deg = angle.rem_euclid(360.0); + self.persist_settings_if_changed(); + } + } + Task::none() + } + + Message::PolarIncrementChanged(deg) => { + if deg.is_finite() && deg > 0.0 { + self.polar_increment_deg = deg; + self.persist_settings_if_changed(); + } + Task::none() + } + + Message::ShowViewCubeChanged(show) => { + self.show_viewcube = show; + self.persist_settings_if_changed(); + Task::none() + } + + Message::ShowUcsIconChanged(show) => { + self.show_ucs_icon = show; + self.persist_settings_if_changed(); + Task::none() + } + + Message::UcsIconAtOriginChanged(at_origin) => { + self.ucs_icon_at_origin = at_origin; + self.persist_settings_if_changed(); + Task::none() + } + + // ISOLINES is baked into solid meshes, so rebuild once on release. + Message::IsolinesChanged(value) => { + let value = value.max(0); + let changed = self + .tabs + .get(self.active_tab) + .is_some_and(|tab| tab.scene.document.header.isolines != value); + if changed { + self.set_drawing_var(|header| header.isolines = value); + self.isolines_awaiting_regen = true; + } + Task::none() + } + + Message::IsolinesReleased => { + if std::mem::take(&mut self.isolines_awaiting_regen) { + self.regenerate_meshes(); + } + Task::none() + } + + Message::DispSilhChanged(on) => { + self.set_drawing_var(|header| header.display_silhouette = on); + Task::none() + } + + Message::SurfaceUChanged(value) => { + self.set_drawing_var(|header| header.surface_u_density = value.clamp(0, 200)); + Task::none() + } + + Message::SurfaceVChanged(value) => { + self.set_drawing_var(|header| header.surface_v_density = value.clamp(0, 200)); + Task::none() + } + + Message::SurfaceTypeChanged(value) => { + self.set_drawing_var(|header| header.surface_type = value); + Task::none() + } + + Message::SolidHistChanged(record) => { + self.set_drawing_var(|header| header.record_solid_history = record); + Task::none() + } + + Message::ShowHistChanged(mode) => { + self.set_drawing_var(|header| { + header.show_solid_history = mode.clamp(0, 2) + }); + let i = self.active_tab; + if let Some(tab) = self.tabs.get_mut(i) { + tab.scene.bump_geometry(); + } + Task::none() + } + + Message::SelectionCyclingChanged(on) => { + self.selection_cycling = on; + self.persist_settings_if_changed(); + Task::none() + } + + Message::OpenFolder(path) => crate::sys::open_url(&path, None), + Message::PickDragRectToggled(rectangle) => { self.pick_drag_rect = rectangle; self.persist_settings_if_changed(); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 6ddc15b3c..a9b4e121f 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1535,15 +1535,30 @@ impl OpenCADStudio { } if let Some(axis) = grip.axis { - snapped = cursor_on_projected_axis( - p, - bounds, - view_rot, - eye, - grip.origin_world, - axis, - ) - .unwrap_or(snapped); + let acquired_point = otrack_hit.is_some() + || snap_hit.is_some_and(|hit| { + hit.snap_type != crate::snap::SnapType::Grid + }); + if acquired_point { + if let Some(axis) = axis.try_normalize() { + // An axis grip still moves on its construction axis, but + // an acquired object point supplies the exact coordinate + // along that axis. This lets height and draft arrows seat + // back onto existing surface/solid geometry. + snapped = grip.origin_world + + axis * (snapped - grip.origin_world).dot(axis); + } + } else { + snapped = cursor_on_projected_axis( + p, + bounds, + view_rot, + eye, + grip.origin_world, + axis, + ) + .unwrap_or(snapped); + } } let snap_ms = snap_started.elapsed().as_secs_f64() * 1000.0; @@ -1565,8 +1580,19 @@ impl OpenCADStudio { let apply_started = Instant::now(); let delta = snapped - grip.last_world; - let lengthen = grip.mode == GripEditMode::Lengthen; - let actions: Vec<_> = if lengthen { + let menu_action = match grip.mode { + GripEditMode::Lengthen => { + Some(crate::scene::model::object::GripMenuAction::Lengthen) + } + GripEditMode::Radius => { + Some(crate::scene::model::object::GripMenuAction::Radius) + } + GripEditMode::ArcLength => { + Some(crate::scene::model::object::GripMenuAction::ArcLength) + } + GripEditMode::Stretch => None, + }; + let actions: Vec<_> = if menu_action.is_some() { Vec::new() } else { grip.targets @@ -1581,14 +1607,13 @@ impl OpenCADStudio { }) .collect() }; - if lengthen { + if let Some(action) = menu_action { let original = self .grip_originals .iter() .find(|(handle, _)| *handle == grip.handle) .map(|(_, entity)| entity.clone()); if let Some(original) = original { - let action = crate::scene::model::object::GripMenuAction::Lengthen; let value = crate::scene::view::dispatch::grip_menu_point_value( &original, grip.grip_id, @@ -1782,6 +1807,26 @@ impl OpenCADStudio { let preview_ms = preview_started.elapsed().as_secs_f64() * 1000.0; let geometry_ms = grip_started.elapsed().as_secs_f64() * 1000.0; self.refresh_selected_grips(); + // A history rebuild can clamp a requested value or move a derived + // construction handle non-linearly. Rebase each active target onto + // the freshly rebuilt grip so the hot arrow remains attached to the + // live surface and reverses immediately from a geometric limit. + let refreshed_targets: Vec<_> = self.tabs[i] + .selected_grip_handles + .iter() + .copied() + .zip(self.tabs[i].selected_grips.iter()) + .map(|(handle, grip)| ((handle, grip.id), grip.world)) + .collect(); + if let Some(active) = self.tabs[i].active_grip.as_mut() { + for target in &mut active.targets { + if let Some((_, world)) = refreshed_targets.iter().find(|((handle, id), _)| { + *handle == target.handle && *id == target.grip_id + }) { + target.last_world = *world; + } + } + } let grips_ms = grip_started.elapsed().as_secs_f64() * 1000.0 - geometry_ms; // Properties are refreshed when the grip is committed or cancelled. // Rebuilding the inspector on every pointer event adds no drawing @@ -3080,7 +3125,11 @@ impl OpenCADStudio { return Task::none(); }; let grip_shape = self.tabs[i].selected_grips[grip_index].shape; - if grip_shape == crate::scene::model::object::GripShape::Dropdown { + if matches!( + grip_shape, + crate::scene::model::object::GripShape::Dropdown + | crate::scene::model::object::GripShape::DropdownAdjacent + ) { use crate::entities::traits::EntityTypeOps; let items = self.tabs[i] .scene @@ -3224,7 +3273,10 @@ impl OpenCADStudio { // Engaging click — stay hot, wait for the placement click. return Task::none(); } - if grip.mode == GripEditMode::Lengthen { + if matches!( + grip.mode, + GripEditMode::Lengthen | GripEditMode::Radius | GripEditMode::ArcLength + ) { self.grip_pending = None; self.command_line.input.clear(); } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index c935be08a..b2f7e780e 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -162,7 +162,7 @@ pub(super) struct RenderModeChoice(pub acadrust::entities::ViewportRenderMode); impl std::fmt::Display for RenderModeChoice { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(crate::modules::view::visual_style::label_for(self.0)) + f.write_str(crate::t!(crate::modules::view::visual_style::label_for(self.0)).as_ref()) } } @@ -974,7 +974,32 @@ bg={bg_ms:.1}ms n={view_count}" // A command may drive a typed scalar by mouse (e.g. a // perpendicular distance to a picked object); show that live // value in the box until the user types over it. - let live = tab.active_cmd.as_ref().and_then(|c| c.dyn_live_value(w)); + let live = tab.active_cmd.as_ref().and_then(|c| c.dyn_live_value(w)).or_else(|| { + let grip = tab.active_grip.as_ref()?; + let action = match grip.mode { + crate::scene::pick::grip::GripEditMode::Lengthen => { + crate::scene::model::object::GripMenuAction::Lengthen + } + crate::scene::pick::grip::GripEditMode::Radius => { + crate::scene::model::object::GripMenuAction::Radius + } + crate::scene::pick::grip::GripEditMode::ArcLength => { + crate::scene::model::object::GripMenuAction::ArcLength + } + _ => return None, + }; + let original = self + .grip_originals + .iter() + .find(|(handle, _)| *handle == grip.handle) + .map(|(_, entity)| entity)?; + crate::scene::view::dispatch::grip_menu_point_value( + original, + grip.grip_id, + action, + w, + ) + }); let boxes: Vec = tab .dyn_fields .iter() @@ -1734,17 +1759,28 @@ bg={bg_ms:.1}ms n={view_count}" // viewport stack rather than as a separate row in the main // column — frees up vertical space when no command is active // and keeps the input close to where the cursor is drawing. - // Autocomplete shows only when no command is collecting its - // own input (otherwise typed prefixes are coordinates / values). - let allow_autocomplete = tab.active_cmd.is_none(); + // Autocomplete shows only when no command or grip-menu action is + // collecting its own input. A pending Lengthen / Radius / Arc Length + // value is numeric input, not the prefix of a new command (for example, + // `3` must not open the 3DALIGN / 3DARRAY suggestions). + let allow_autocomplete = tab.active_cmd.is_none() && self.grip_pending.is_none(); // Dynamic input captures keystrokes when its fields are showing, // so the command-line field must release focus / its on_input. // The MText preview also captures keystrokes (typing edits it), so the // command line must likewise release its on_input there. + let interactive_value_grip = tab.active_grip.as_ref().is_some_and(|grip| { + matches!( + grip.mode, + crate::scene::pick::grip::GripEditMode::Lengthen + | crate::scene::pick::grip::GripEditMode::Radius + | crate::scene::pick::grip::GripEditMode::ArcLength + ) + }); let dyn_capturing = (self.dyn_input && (tab.active_cmd.is_some() || tab.active_grip.is_some()) - && !tab.dyn_fields.is_empty()) + && !tab.dyn_fields.is_empty() + && !interactive_value_grip) || self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) || self.text_inline.is_some(); // The workspace row is: left edge stack, viewport, right edge stack. diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 2e749248a..0c52aa9ac 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -191,8 +191,8 @@ impl OpenCADStudio { } super::super::ModalKind::Options => sized_flow( ex, - 540, - 560, + 880, + 620, |flow| { crate::ui::window::options::view_window( &self.default_save_format, @@ -209,6 +209,55 @@ impl OpenCADStudio { pick_add: self.pick_add, pick_drag_rect: self.pick_drag_rect, grip_object_limit: self.grip_object_limit, + selection_cycling: self.selection_cycling, + }, + crate::ui::window::options::AppPrefs { + savetime_min: self.savetime_min, + backup_on_save: self.backup_on_save, + textfill: crate::scene::text::sdf_atlas::textfill(), + cliprompt_lines: self.cliprompt_lines, + commandline_fade_ms: self.commandline_fade_ms, + zoom_wheel_reversed: self.zoom_wheel_reversed, + zoom_factor: self.zoom_factor, + texteditmode: self.texteditmode, + dimension_continue_mode: self.dimension_continue_mode, + qdim_snap_priority: self.quick_dimension_snap_priority, + annotation_auto_scale: self.annotation_auto_scale, + polar_increment_deg: self.polar_increment_deg, + show_viewcube: self.show_viewcube, + show_ucs_icon: self.show_ucs_icon, + ucs_icon_at_origin: self.ucs_icon_at_origin, + }, + &self.snap_angle_input, + { + let header = self + .tabs + .get(self.active_tab) + .map(|tab| &tab.scene.document.header); + crate::ui::window::options::DrawingPrefs { + available: header.is_some(), + isolines: header.map_or(4, |h| h.isolines), + display_silhouette: header + .is_some_and(|h| h.display_silhouette), + surface_u: header.map_or(6, |h| h.surface_u_density), + surface_v: header.map_or(6, |h| h.surface_v_density), + surface_type: header.map_or(6, |h| h.surface_type), + record_solid_history: header + .is_some_and(|h| h.record_solid_history), + show_solid_history: header + .map_or(1, |h| h.show_solid_history), + } + }, + crate::ui::window::options::Folders { + config: crate::config::config_dir() + .map(|p| p.display().to_string()), + plot_styles: crate::io::plot_style::plot_styles_dir() + .ok() + .map(|p| p.display().to_string()), + plugins: crate::plugin::external::plugins_dir() + .map(|p| p.display().to_string()), + autosave: crate::config::config_dir() + .map(|_| std::env::temp_dir().display().to_string()), }, self.double_click_block_refedit, self.double_click_block_attedit, diff --git a/src/command.rs b/src/command.rs index 8250dbfdb..5225c401a 100644 --- a/src/command.rs +++ b/src/command.rs @@ -744,6 +744,88 @@ impl CadCommand for TwoValuePromptCommand { } } +// ── Mid between 2 points (MTP / M2P) ──────────────────────────────────────── + +/// Point-entry modifier that prompts for two points, draws a preview connecting line +/// with a midpoint marker, and returns the midpoint to the caller. +#[derive(Debug, Default)] +pub struct Mid2PointCommand { + pub first_point: Option, +} + +impl Mid2PointCommand { + pub fn new() -> Self { + Self { first_point: None } + } +} + +impl CadCommand for Mid2PointCommand { + fn name(&self) -> &'static str { + "MTP" + } + + fn prompt(&self) -> String { + if self.first_point.is_none() { + crate::t!("_mtp Specify first point of mid:").into_owned() + } else { + crate::t!("_mtp Specify second point of mid:").into_owned() + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + if let Some(first) = self.first_point { + let mid = (first + pt) * 0.5; + CmdResult::ReturnPoint(mid) + } else { + self.first_point = Some(pt); + CmdResult::NeedPoint + } + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + let mut wires = Vec::new(); + if let Some(first) = self.first_point { + // Rubber-band line connecting the first point to the cursor + wires.push(WireModel::solid_f64( + "mtp_rubber_band".to_string(), + vec![[first.x, first.y, first.z], [pt.x, pt.y, pt.z]], + WireModel::CYAN, + false, + )); + // Midpoint marker (AutoCAD style triangle glyph) + let mid = (first + pt) * 0.5; + let dist = (pt - first).length(); + let s = (dist * 0.02).clamp(0.5, 10.0); + let h = s * 1.5; + let w = s * 1.0; + wires.push(WireModel::solid_f64( + "mtp_mid_triangle".to_string(), + vec![ + [mid.x, mid.y + h * (2.0 / 3.0), mid.z], + [mid.x - w, mid.y - h * (1.0 / 3.0), mid.z], + [mid.x + w, mid.y - h * (1.0 / 3.0), mid.z], + ], + WireModel::CYAN, + true, + )); + } + wires + } +} + +inventory::submit!(CommandRegistration { + names: &["MTP", "M2P"], +}); + + /// Generic interactive front-end for a keyword command that operates on the /// current selection (CHPROP, ADJUST, XDATA, UNDERLAY, DRAWORDER…). If nothing /// is selected when it starts it first gathers a selection (Enter confirms), @@ -1689,6 +1771,8 @@ pub enum CmdResult { UndoDocument, /// Sets the TEXTEDITMODE system variable and ends the command. SetTexteditMode(bool), + /// Return a resolved point back to a suspended parent command (e.g. MTP / M2P). + ReturnPoint(DVec3), } /// What kind of value the active command is currently asking for. Drives @@ -2485,3 +2569,42 @@ mod constraint_registry_tests { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mid2point_command() { + let mut cmd = Mid2PointCommand::new(); + assert_eq!(cmd.name(), "MTP"); + assert_eq!(cmd.prompt(), "_mtp Specify first point of mid:"); + + // First point + let res = cmd.on_point(DVec3::new(10.0, 20.0, 0.0)); + assert!(matches!(res, CmdResult::NeedPoint)); + assert_eq!(cmd.prompt(), "_mtp Specify second point of mid:"); + + // Preview wires + let wires = cmd.on_preview_wires(DVec3::new(30.0, 40.0, 0.0)); + assert!(!wires.is_empty()); + + // Second point + let res = cmd.on_point(DVec3::new(30.0, 40.0, 0.0)); + assert!(matches!(res, CmdResult::ReturnPoint(mid) if mid == DVec3::new(20.0, 30.0, 0.0))); + } + + #[test] + fn test_mid2point_cancel() { + let mut cmd = Mid2PointCommand::new(); + assert!(matches!(cmd.on_escape(), CmdResult::Cancel)); + assert!(matches!(cmd.on_enter(), CmdResult::Cancel)); + } + + #[test] + fn test_mtp_registered() { + let names = all_registered_command_names(); + assert!(names.contains(&"MTP")); + assert!(names.contains(&"M2P")); + } +} diff --git a/src/entities/arc.rs b/src/entities/arc.rs index 3f2ffaffd..e4f35e9cb 100644 --- a/src/entities/arc.rs +++ b/src/entities/arc.rs @@ -364,19 +364,44 @@ impl crate::entities::traits::Grippable for Arc { action: crate::scene::model::object::GripMenuAction, point: glam::DVec3, ) -> Option { + use cadkernel::geom2d::{Circle as KernelCircle, Curve as KernelCurve, Vec2}; use crate::scene::model::object::GripMenuAction as A; - if !matches!(action, A::Lengthen) || self.radius <= 1.0e-9 { + if self.radius <= 1.0e-9 { return None; } - let (x, y, _) = crate::scene::view::transform::wcs_point_to_ocs( - (point.x, point.y, point.z), - (self.normal.x, self.normal.y, self.normal.z), - ); - let cursor_angle = (y - self.center.y).atan2(x - self.center.x); - let current_sweep = (self.end_angle - self.start_angle).rem_euclid(TAU); + let curve = crate::entities::curve::arc_curve(self); + let point = curve.plane.project(point.to_array())?; + let KernelCurve::Arc(arc) = &curve.curve else { + unreachable!("arc entity must produce an arc curve") + }; + if matches!(action, A::Radius) && grip_id == 3 { + let radius = Vec2::from(point).distance(Vec2::from(arc.centre)); + return (radius > 1.0e-9).then_some(radius); + } + if matches!(action, A::ArcLength) && grip_id == 3 { + let middle = Vec2::from(curve.curve.point_at(0.5)); + let tangent = Vec2::from(curve.curve.tangent_at(0.5)).normalize()?; + let length = curve.length() + (Vec2::from(point) - middle).dot(tangent); + let circumference = KernelCircle { + centre: arc.centre, + radius: arc.radius, + } + .length(); + return (length > 1.0e-9 && length < circumference - 1.0e-9).then_some(length); + } + if !matches!(action, A::Lengthen) { + return None; + } + let cursor_angle = KernelCurve::Circle(KernelCircle { + centre: arc.centre, + radius: arc.radius, + }) + .parameter_at(point) + * TAU; + let current_sweep = arc.sweep(); let desired_sweep = match grip_id { - 1 => (self.end_angle - cursor_angle).rem_euclid(TAU), - 2 => (cursor_angle - self.start_angle).rem_euclid(TAU), + 1 => (arc.end_angle - cursor_angle).rem_euclid(TAU), + 2 => (cursor_angle - arc.start_angle).rem_euclid(TAU), _ => return None, }; if desired_sweep <= 1.0e-9 { @@ -458,3 +483,48 @@ impl crate::entities::traits::MassPropsCalc for acadrust::entities::Arc { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::entities::traits::Grippable; + use crate::scene::model::object::GripMenuAction; + + #[test] + fn midpoint_grip_drives_radius_and_arc_length_in_entity_plane() { + let mut arc = Arc::default(); + arc.center = acadrust::types::Vector3::new(1.0, 2.0, 3.0); + arc.normal = acadrust::types::Vector3::new(0.0, 1.0, 0.0); + arc.radius = 2.0; + arc.start_angle = 0.0; + arc.end_angle = std::f64::consts::FRAC_PI_2; + + let curve = crate::entities::curve::arc_curve(&arc); + let middle_angle = std::f64::consts::FRAC_PI_4; + let radius_point = curve.plane.point_at([ + arc.center.x + 5.0 * middle_angle.cos(), + arc.center.y + 5.0 * middle_angle.sin(), + ]); + let radius = arc + .grip_menu_point_value(3, GripMenuAction::Radius, radius_point.into()) + .expect("radius value"); + assert!((radius - 5.0).abs() < 1.0e-9); + + let middle = glam::DVec3::from_array(curve.point_at(0.5)); + let tangent = glam::DVec3::from_array(curve.tangent_at(0.5)).normalize(); + let wanted_length = curve.length() + 1.25; + let arc_length = arc + .grip_menu_point_value( + 3, + GripMenuAction::ArcLength, + middle + tangent * 1.25, + ) + .expect("arc length value"); + assert!((arc_length - wanted_length).abs() < 1.0e-9); + + arc.apply_grip_menu_value(3, GripMenuAction::ArcLength, arc_length); + assert!( + (crate::entities::curve::arc_curve(&arc).length() - wanted_length).abs() < 1.0e-9 + ); + } +} diff --git a/src/entities/circle.rs b/src/entities/circle.rs index bc037abfb..7d596506e 100644 --- a/src/entities/circle.rs +++ b/src/entities/circle.rs @@ -256,7 +256,89 @@ impl RenderConvertible for Circle { } } -crate::impl_entity_basics!(Circle); +impl crate::entities::traits::Grippable for Circle { + fn grips(&self) -> Vec { + grips(self) + } + + fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { + apply_grip(self, grip_id, apply); + } + + fn grip_menu( + &self, + grip_id: usize, + ) -> Vec { + use crate::scene::model::object::{GripMenuAction, GripMenuItem}; + let mut items = vec![GripMenuItem { + label: "Stretch", + action: GripMenuAction::Stretch, + }]; + if (1..=4).contains(&grip_id) { + items.push(GripMenuItem { + label: "Radius", + action: GripMenuAction::Radius, + }); + } + items + } + + fn grip_menu_value_prompt( + &self, + _grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + ) -> Option<&'static str> { + matches!(action, crate::scene::model::object::GripMenuAction::Radius) + .then_some("New radius") + } + + fn grip_menu_point_value( + &self, + grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + point: glam::DVec3, + ) -> Option { + if !matches!(action, crate::scene::model::object::GripMenuAction::Radius) + || !(1..=4).contains(&grip_id) + { + return None; + } + let plane = crate::entities::curve::circle_curve(self).plane; + let point = plane.project(point.to_array())?; + let radius = (point[0] - self.center.x).hypot(point[1] - self.center.y); + (radius > 1.0e-9).then_some(radius) + } + + fn apply_grip_menu_value( + &mut self, + grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + value: f64, + ) { + if matches!(action, crate::scene::model::object::GripMenuAction::Radius) + && (1..=4).contains(&grip_id) + && value > 1.0e-9 + { + self.radius = value; + } + } +} + +impl crate::entities::traits::PropertyEditable for Circle { + fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { + properties(self) + } + + fn apply_geom_prop(&mut self, field: &str, value: &str) { + apply_geom_prop(self, field, value); + } +} + +impl crate::entities::traits::Transformable for Circle { + fn apply_transform(&mut self, transform: &EntityTransform) { + apply_transform(self, transform); + } +} impl crate::entities::traits::MassPropsCalc for Circle { fn mass_props(&self) -> crate::entities::traits::MassProps { @@ -276,6 +358,28 @@ impl crate::entities::traits::MassPropsCalc for Circle { mod tests { use super::*; + #[test] + fn quadrant_grips_offer_interactive_radius() { + use crate::entities::traits::Grippable; + use crate::scene::model::object::GripMenuAction; + + let mut circle = Circle::default(); + circle.radius = 2.0; + + assert_eq!(circle.grip_menu(0).len(), 1); + assert!(circle + .grip_menu(1) + .iter() + .any(|item| item.action == GripMenuAction::Radius)); + assert_eq!( + circle.grip_menu_value_prompt(1, GripMenuAction::Radius), + Some("New radius") + ); + + circle.apply_grip_menu_value(1, GripMenuAction::Radius, 5.0); + assert_eq!(circle.radius, 5.0); + } + #[test] fn test_circle_segments_scales_with_zoom() { let r = 10.0; @@ -361,4 +465,3 @@ mod tests { assert!(close_count > far_count, "{close_count} <= {far_count}"); } } - diff --git a/src/entities/common.rs b/src/entities/common.rs index 1d1fbee41..57af9ab2f 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -4,6 +4,8 @@ use std::collections::HashSet; use crate::scene::model::object::{GripDef, GripShape, PropValue, Property}; use crate::scene::model::wire_model::PatternStationPiece; +pub const VARIES_LABEL: &str = "*VARIES*"; + /// Linear / angular unit format pulled from the document header so the /// per-thread properties pipeline can format values consistently without /// passing the document through every callsite. diff --git a/src/entities/extended.rs b/src/entities/extended.rs index 38fe46ddd..3712029a3 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -13,13 +13,19 @@ use crate::entities::common::{ use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject}; use crate::scene::model::object::{ - GripApply, GripDef, PropSection, PropValue, Property, + GripApply, GripDef, GripMenuAction, GripMenuItem, PropSection, PropValue, Property, }; use crate::scene::model::wire_model::SnapHint; const NAN: [f64; 3] = [f64::NAN; 3]; const SECTION_SLICE_APP: &str = "IsSlice"; const SECTION_THICKNESS_APP: &str = "ThicknessDepth"; +const SECTION_GRIP_LEFT: usize = 100_000; +const SECTION_GRIP_RIGHT: usize = 100_001; +const SECTION_GRIP_TOP: usize = 100_002; +const SECTION_GRIP_BOTTOM: usize = 100_003; +const SECTION_GRIP_NORMAL: usize = 100_004; +const SECTION_GRIP_STATE: usize = 100_005; pub(crate) fn section_is_slice(entity: &ExtendedEntity) -> bool { entity @@ -1563,15 +1569,56 @@ fn move_extents( fn grips(entity: &ExtendedEntity) -> Vec { match &entity.data { - ExtendedEntityData::SectionObject(data) => data - .vertices - .iter() - .chain(data.back_line_vertices.iter()) - .enumerate() - .map(|(index, point)| { - square_grip(index, glam::DVec3::new(point.x, point.y, point.z)) - }) - .collect(), + ExtendedEntityData::SectionObject(data) => { + let mut grips: Vec = if section_kind(entity, data) == "Plane" + && data.vertices.len() == 2 + && data.back_line_vertices.is_empty() + { + section_plane_edge_grips(data) + } else { + data.vertices + .iter() + .chain(data.back_line_vertices.iter()) + .enumerate() + .map(|(index, point)| { + square_grip(index, glam::DVec3::new(point.x, point.y, point.z)) + }) + .collect() + }; + if let Some(right) = data.vertices.last() { + let vertical = normalized(data.vertical_direction, Vector3::UNIT_Z); + let edge_center_offset = (data.top_height - data.bottom_height) * 0.5; + let anchor = *right + vertical * edge_center_offset; + grips.push(GripDef { + id: SECTION_GRIP_STATE, + world: glam::DVec3::new(anchor.x, anchor.y, anchor.z), + is_midpoint: false, + shape: crate::scene::model::object::GripShape::DropdownAdjacent, + dir: None, + axis: None, + }); + } + if let Some(center) = section_center(data) { + let normal = section_viewing_direction(data); + let span = data + .vertices + .first() + .zip(data.vertices.last()) + .map_or(1.0, |(first, last)| (*last - *first).length()); + let offset = (span * 0.08).max(1.0e-6); + let normal_world = glam::DVec3::new(normal.x, normal.y, normal.z); + grips.push(GripDef { + id: SECTION_GRIP_NORMAL, + world: glam::DVec3::new(center.x, center.y, center.z) + - normal_world * offset, + is_midpoint: false, + shape: crate::scene::model::object::GripShape::Triangle, + dir: Some(-normal_world), + axis: Some(normal_world), + }); + } + grips + } ExtendedEntityData::ArcAlignedText(data) => vec![center_grip( 0, glam::DVec3::new(data.center.x, data.center.y, data.center.z), @@ -1643,7 +1690,41 @@ fn grips(entity: &ExtendedEntity) -> Vec { fn apply_grip(entity: &mut ExtendedEntity, grip_id: usize, apply: GripApply) { match &mut entity.data { ExtendedEntityData::SectionObject(data) => { - if grip_id < data.vertices.len() { + if matches!( + grip_id, + SECTION_GRIP_LEFT + | SECTION_GRIP_RIGHT + | SECTION_GRIP_TOP + | SECTION_GRIP_BOTTOM + ) { + apply_section_plane_edge_grip(data, grip_id, apply); + } else if grip_id == SECTION_GRIP_NORMAL { + let Some(center) = section_center(data) else { + return; + }; + let normal = section_viewing_direction(data); + let span = data + .vertices + .first() + .zip(data.vertices.last()) + .map_or(1.0, |(first, last)| (*last - *first).length()); + let offset = (span * 0.08).max(1.0e-6); + let current = center - normal * offset; + let delta = match apply { + GripApply::Translate(delta) => Vector3::new(delta.x, delta.y, delta.z), + GripApply::Absolute(position) => { + Vector3::new(position.x, position.y, position.z) - current + } + }; + let constrained = normal * delta.dot(&normal); + for point in data + .vertices + .iter_mut() + .chain(data.back_line_vertices.iter_mut()) + { + *point = *point + constrained; + } + } else if grip_id < data.vertices.len() { apply_point(&mut data.vertices[grip_id], apply); } else if let Some(point) = data .back_line_vertices @@ -1704,6 +1785,115 @@ fn apply_grip(entity: &mut ExtendedEntity, grip_id: usize, apply: GripApply) { } } +fn section_center(data: &SectionObjectData) -> Option { + let (first, last) = data.vertices.first().zip(data.vertices.last())?; + Some((*first + *last) * 0.5) +} + +fn section_plane_edge_grips(data: &SectionObjectData) -> Vec { + let Some((first, last)) = data.vertices.first().zip(data.vertices.last()) else { + return Vec::new(); + }; + let center = (*first + *last) * 0.5; + let tangent = section_tangent(data); + let vertical = normalized(data.vertical_direction, Vector3::UNIT_Z); + let edge_center_offset = (data.top_height - data.bottom_height) * 0.5; + let definitions = [ + ( + SECTION_GRIP_LEFT, + *first + vertical * edge_center_offset, + vertical, + None, + ), + ( + SECTION_GRIP_RIGHT, + *last + vertical * edge_center_offset, + vertical, + None, + ), + ( + SECTION_GRIP_TOP, + center + vertical * data.top_height, + tangent, + Some(vertical), + ), + ( + SECTION_GRIP_BOTTOM, + center - vertical * data.bottom_height, + tangent, + Some(vertical), + ), + ]; + definitions + .into_iter() + .map(|(id, world, edge, axis)| GripDef { + id, + world: glam::DVec3::new(world.x, world.y, world.z), + is_midpoint: false, + shape: crate::scene::model::object::GripShape::Rectangle, + dir: Some(glam::DVec3::new(edge.x, edge.y, edge.z)), + axis: axis.map(|axis| glam::DVec3::new(axis.x, axis.y, axis.z)), + }) + .collect() +} + +fn apply_section_plane_edge_grip( + data: &mut SectionObjectData, + grip_id: usize, + apply: GripApply, +) { + let Some((first, last)) = data.vertices.first().zip(data.vertices.last()) else { + return; + }; + let center = (*first + *last) * 0.5; + let tangent = section_tangent(data); + let vertical = normalized(data.vertical_direction, Vector3::UNIT_Z); + let edge_center_offset = (data.top_height - data.bottom_height) * 0.5; + let current = match grip_id { + SECTION_GRIP_LEFT => *first + vertical * edge_center_offset, + SECTION_GRIP_RIGHT => *last + vertical * edge_center_offset, + SECTION_GRIP_TOP => center + vertical * data.top_height, + SECTION_GRIP_BOTTOM => center - vertical * data.bottom_height, + _ => return, + }; + let delta = match apply { + GripApply::Translate(delta) => Vector3::new(delta.x, delta.y, delta.z), + GripApply::Absolute(position) => { + Vector3::new(position.x, position.y, position.z) - current + } + }; + let span = (*last - *first).length(); + let minimum_span = 1.0e-6; + let in_plane_vertical = normalized( + vertical - tangent * vertical.dot(&tangent), + vertical, + ); + let vertical_change = in_plane_vertical * delta.dot(&in_plane_vertical); + match grip_id { + SECTION_GRIP_LEFT => { + let tangent_change = delta + .dot(&tangent) + .min((span - minimum_span).max(0.0)); + data.vertices[0] = data.vertices[0] + tangent * tangent_change + vertical_change; + } + SECTION_GRIP_RIGHT => { + let last = data.vertices.len() - 1; + let tangent_change = delta + .dot(&tangent) + .max((-span + minimum_span).min(0.0)); + data.vertices[last] = + data.vertices[last] + tangent * tangent_change + vertical_change; + } + SECTION_GRIP_TOP => { + data.top_height = (data.top_height + delta.dot(&vertical)).max(0.0); + } + SECTION_GRIP_BOTTOM => { + data.bottom_height = (data.bottom_height - delta.dot(&vertical)).max(0.0); + } + _ => {} + } +} + fn entity_transform(transform: &EntityTransform) -> Transform { match transform { EntityTransform::Translate(delta) => { @@ -1943,6 +2133,60 @@ impl Grippable for ExtendedEntity { fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { apply_grip(self, grip_id, apply); } + + fn grip_menu(&self, grip_id: usize) -> Vec { + if grip_id != SECTION_GRIP_STATE { + return Vec::new(); + } + let ExtendedEntityData::SectionObject(data) = &self.data else { + return Vec::new(); + }; + let current = section_kind(self, data); + vec![ + GripMenuItem { + label: if current == "Plane" { "✓ Plane" } else { "Plane" }, + action: GripMenuAction::SectionPlane, + }, + GripMenuItem { + label: if current == "Slice" { "✓ Slice" } else { "Slice" }, + action: GripMenuAction::SectionSlice, + }, + GripMenuItem { + label: if current == "Boundary" { + "✓ Boundary" + } else { + "Boundary" + }, + action: GripMenuAction::SectionBoundary, + }, + GripMenuItem { + label: if current == "Volume" { "✓ Volume" } else { "Volume" }, + action: GripMenuAction::SectionVolume, + }, + ] + } + + fn apply_grip_menu(&mut self, grip_id: usize, action: GripMenuAction) { + if grip_id != SECTION_GRIP_STATE + || !matches!(self.data, ExtendedEntityData::SectionObject(_)) + { + return; + } + let value = match action { + GripMenuAction::SectionPlane => "Plane", + GripMenuAction::SectionSlice => "Slice", + GripMenuAction::SectionBoundary => "Boundary", + GripMenuAction::SectionVolume => "Volume", + _ => return, + }; + let ExtendedEntityData::SectionObject(data) = &self.data else { + return; + }; + if section_kind(self, data) == value { + return; + } + apply_section_prop(self, "ext_section_state", value); + } } impl PropertyEditable for ExtendedEntity { @@ -1960,3 +2204,35 @@ impl Transformable for ExtendedEntity { apply_transform(self, transform); } } + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::EntityCommon; + use acadrust::types::Color; + + #[test] + fn reselecting_slice_keeps_its_depth() { + let mut entity = ExtendedEntity { + common: EntityCommon::new(), + data: ExtendedEntityData::SectionObject(SectionObjectData { + state: 1, + flags: 5, + name: String::new(), + vertical_direction: Vector3::UNIT_Z, + top_height: 1.0, + bottom_height: 1.0, + indicator_alpha: 70, + indicator_color: Color::from_index(9), + back_line_vertices: Vec::new(), + vertices: vec![Vector3::ZERO, Vector3::new(60.0, 0.0, 0.0)], + settings_handle: Handle::NULL, + }), + }; + set_section_slice_metadata(&mut entity, true, 0.25); + + entity.apply_grip_menu(SECTION_GRIP_STATE, GripMenuAction::SectionSlice); + + assert_eq!(section_slice_depth(&entity), Some(0.25)); + } +} diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index 8b125fe39..67fa6e418 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -6,7 +6,7 @@ use crate::t; use crate::command::EntityTransform; use crate::entities::common::{ edit_prop as edit, edit_scalar_prop as edit_scalar, format_area, format_length, parse_f64, - rectangle_grip, ro_prop as ro, square_grip, stepper_prop as stepper, + rectangle_grip, ro_prop as ro, square_grip, stepper_prop as stepper, VARIES_LABEL, }; use crate::entities::traits::RenderConvertible; use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject}; @@ -18,14 +18,38 @@ const REVCLOUD_BULGE: f64 = 0.5; const MAX_REVCLOUD_VERTICES: usize = 100_000; const WIDTH_EPSILON: f64 = 1.0e-9; -fn effective_width(width: f64, constant_width: f64) -> f64 { - if width > WIDTH_EPSILON { - width +pub(crate) fn vertex_segment_widths(vertex: &LwVertex, constant_width: f64) -> (f64, f64) { + if vertex.start_width > WIDTH_EPSILON || vertex.end_width > WIDTH_EPSILON { + (vertex.start_width, vertex.end_width) } else { - constant_width + (constant_width, constant_width) } } +pub(crate) fn lwpolyline_global_width(pline: &LwPolyline) -> Option { + let count = pline.vertices.len(); + let seg_count = if pline.is_closed { + count + } else { + count.saturating_sub(1) + }; + if seg_count == 0 { + return Some(pline.constant_width); + } + let (w0_start, w0_end) = vertex_segment_widths(&pline.vertices[0], pline.constant_width); + if (w0_start - w0_end).abs() > 1e-6 { + return None; + } + let w0 = w0_start; + for i in 1..seg_count { + let (sw, ew) = vertex_segment_widths(&pline.vertices[i], pline.constant_width); + if (sw - ew).abs() > 1e-6 || (sw - w0).abs() > 1e-6 { + return None; + } + } + Some(w0) +} + /// Midpoint position on an arc segment defined by its bulge. fn arc_midpoint(p0: [f64; 2], p1: [f64; 2], bulge: f64) -> [f64; 2] { match crate::entities::common::BulgeArc::from_bulge(p0, p1, bulge) { @@ -504,8 +528,7 @@ fn band_verts(pline: &LwPolyline) -> Vec<([f64; 2], f64, f64, f64)> { .vertices .iter() .map(|v| { - let sw = effective_width(v.start_width, c); - let ew = effective_width(v.end_width, c); + let (sw, ew) = vertex_segment_widths(v, c); ([v.location.x, v.location.y], v.bulge, sw, ew) }) .collect() @@ -899,11 +922,8 @@ fn properties(pline: &LwPolyline) -> Vec { let v = pline.vertices.get(vi); let vx = v.map_or(0.0, |v| v.location.x); let vy = v.map_or(0.0, |v| v.location.y); - let start_w = v.map_or(0.0, |v| { - effective_width(v.start_width, pline.constant_width) - }); - let end_w = v.map_or(0.0, |v| { - effective_width(v.end_width, pline.constant_width) + let (start_w, end_w) = v.map_or((0.0, 0.0), |v| { + vertex_segment_widths(v, pline.constant_width) }); let mp = ::mass_props(pline); let cloud_arc_length = revision_cloud_arc_length(pline); @@ -937,6 +957,14 @@ fn properties(pline: &LwPolyline) -> Vec { arc_length, )); } + let global_width_prop = match lwpolyline_global_width(pline) { + Some(gw) => edit(t!("Global width").as_ref(), "global_width", gw), + None => Property { + label: t!("Global width").into_owned(), + field: "global_width", + value: PropValue::EditText(VARIES_LABEL.to_string()), + }, + }; let mut geometry_props = vec![ stepper(t!("Current Vertex").as_ref(), "current_vertex", vertex_label), edit(t!("Vertex X").as_ref(), "vertex_x", vx), @@ -948,7 +976,7 @@ fn properties(pline: &LwPolyline) -> Vec { geometry_props.push(edit(t!("End segment width").as_ref(), "end_width", end_w)); } geometry_props.extend([ - edit(t!("Global width").as_ref(), "global_width", pline.constant_width), + global_width_prop, edit(t!("Elevation").as_ref(), "elevation", pline.elevation), ro(t!("Area").as_ref(), "area", format_area(mp.area)), ro(t!("Length").as_ref(), "length", format_length(mp.perimeter)), @@ -1015,7 +1043,13 @@ fn apply_geom_prop(pline: &mut LwPolyline, field: &str, value: &str) { }; match field { "elevation" => pline.elevation = v, - "global_width" if v.is_finite() && v >= 0.0 => pline.constant_width = v, + "global_width" if v.is_finite() && v >= 0.0 => { + pline.constant_width = v; + for vtx in &mut pline.vertices { + vtx.start_width = v; + vtx.end_width = v; + } + } "vertex_x" => { if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.location.x = v; @@ -1027,11 +1061,31 @@ fn apply_geom_prop(pline: &mut LwPolyline, field: &str, value: &str) { } } "start_width" if v.is_finite() && v >= 0.0 => { + if pline.constant_width > WIDTH_EPSILON { + let c = pline.constant_width; + for vtx in &mut pline.vertices { + if vtx.start_width <= WIDTH_EPSILON && vtx.end_width <= WIDTH_EPSILON { + vtx.start_width = c; + vtx.end_width = c; + } + } + pline.constant_width = 0.0; + } if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.start_width = v; } } "end_width" if v.is_finite() && v >= 0.0 => { + if pline.constant_width > WIDTH_EPSILON { + let c = pline.constant_width; + for vtx in &mut pline.vertices { + if vtx.start_width <= WIDTH_EPSILON && vtx.end_width <= WIDTH_EPSILON { + vtx.start_width = c; + vtx.end_width = c; + } + } + pline.constant_width = 0.0; + } if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.end_width = v; } @@ -1252,8 +1306,8 @@ impl crate::entities::traits::Grippable for LwPolyline { new_v.location.x = midpoint[0]; new_v.location.y = midpoint[1]; new_v.vertex_id = 0; - let effective_start = effective_width(v0.start_width, self.constant_width); - let effective_end = effective_width(v0.end_width, self.constant_width); + let (effective_start, effective_end) = + vertex_segment_widths(&v0, self.constant_width); let middle_width = (effective_start + effective_end) * 0.5; self.vertices[i0].end_width = middle_width; new_v.start_width = middle_width; @@ -1325,8 +1379,9 @@ pub(crate) fn wide_fills(pl: &acadrust::entities::LwPolyline) -> ([f64; 2], Vec< for i in 0..seg_count { let v0 = &verts[i]; let v1 = &verts[(i + 1) % n]; - let hw0 = effective_width(v0.start_width, pl.constant_width) as f32 * 0.5; - let hw1 = effective_width(v0.end_width, pl.constant_width) as f32 * 0.5; + let (sw, ew) = vertex_segment_widths(v0, pl.constant_width); + let hw0 = sw as f32 * 0.5; + let hw1 = ew as f32 * 0.5; if hw0 < 1e-6 && hw1 < 1e-6 { continue; } @@ -1381,3 +1436,88 @@ impl crate::entities::traits::MassPropsCalc for acadrust::entities::LwPolyline { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::entities::traits::PropertyEditable; + use acadrust::entities::{LwPolyline, LwVertex}; + use acadrust::Vector2; + + fn make_test_lwpolyline(count: usize, constant_width: f64) -> LwPolyline { + let mut pl = LwPolyline::default(); + pl.constant_width = constant_width; + for i in 0..count { + pl.vertices.push(LwVertex::new(Vector2::new(i as f64 * 10.0, 0.0))); + } + pl + } + + #[test] + fn test_lwpolyline_uniform_width_shows_global_width() { + let pl = make_test_lwpolyline(3, 5.0); + assert_eq!(lwpolyline_global_width(&pl), Some(5.0)); + + let props = pl.geometry_properties(&[]); + let geom_props = &props[0].props; + let gw = geom_props.iter().find(|p| p.field == "global_width").unwrap(); + match &gw.value { + PropValue::EditText(val) => assert_eq!(parse_f64(val), Some(5.0)), + _ => panic!("expected EditText"), + } + } + + #[test] + fn test_lwpolyline_varying_vertex_width_shows_varies() { + let mut pl = make_test_lwpolyline(3, 5.0); + pl.vertices[0].start_width = 2.0; + pl.vertices[0].end_width = 3.0; + + assert_eq!(lwpolyline_global_width(&pl), None); + + let props = pl.geometry_properties(&[]); + let geom_props = &props[0].props; + let gw = geom_props.iter().find(|p| p.field == "global_width").unwrap(); + match &gw.value { + PropValue::EditText(val) => assert_eq!(val, VARIES_LABEL), + _ => panic!("expected EditText with VARIES_LABEL"), + } + } + + #[test] + fn test_lwpolyline_modifying_global_width_sets_all_vertex_widths() { + let mut pl = make_test_lwpolyline(3, 5.0); + pl.vertices[0].start_width = 2.0; + pl.vertices[0].end_width = 3.0; + + pl.apply_geom_prop("global_width", "10.0"); + + assert_eq!(pl.constant_width, 10.0); + for v in &pl.vertices { + assert_eq!(v.start_width, 10.0); + assert_eq!(v.end_width, 10.0); + } + assert_eq!(lwpolyline_global_width(&pl), Some(10.0)); + } + + #[test] + fn test_lwpolyline_modifying_segment_width_materializes_constant_width() { + let mut pl = make_test_lwpolyline(3, 5.0); + // Initially constant_width is 5.0, vertices start/end are 0.0 + assert_eq!(pl.vertices[0].start_width, 0.0); + + // Edit start width of current vertex (vertex 0) to 2.0 + pl.apply_geom_prop("start_width", "2.0"); + + // Vertex 0 start width should be 2.0, end width materialized to 5.0 + assert_eq!(pl.vertices[0].start_width, 2.0); + assert_eq!(pl.vertices[0].end_width, 5.0); + + // Vertex 1 and 2 should have materialized 5.0 instead of collapsing to 0.0 + assert_eq!(pl.vertices[1].start_width, 5.0); + assert_eq!(pl.vertices[1].end_width, 5.0); + + // Global width must now be VARIES + assert_eq!(lwpolyline_global_width(&pl), None); + } +} diff --git a/src/entities/polyline.rs b/src/entities/polyline.rs index de67a35dd..5b23a3ead 100644 --- a/src/entities/polyline.rs +++ b/src/entities/polyline.rs @@ -4,7 +4,7 @@ use crate::t; use crate::command::EntityTransform; use crate::entities::common::{ edit_prop as edit, format_length, parse_f64, ro_prop as ro, square_grip, - stepper_prop as stepper, + stepper_prop as stepper, VARIES_LABEL, }; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject}; @@ -609,6 +609,51 @@ impl Grippable for Polyline2D { } } +pub(crate) fn polyline2d_vertex_segment_widths( + vertex: &acadrust::entities::Vertex2D, + default_start: f64, + default_end: f64, +) -> (f64, f64) { + if vertex.start_width > 1e-9 || vertex.end_width > 1e-9 { + (vertex.start_width, vertex.end_width) + } else { + (default_start, default_end) + } +} + +pub(crate) fn polyline2d_global_width(pline: &Polyline2D) -> Option { + let filtered = drawn_vertices2d(pline); + let verts: &[acadrust::entities::Vertex2D] = + filtered.as_deref().unwrap_or(&pline.vertices); + let count = verts.len(); + let seg_count = if pline.is_closed() { + count + } else { + count.saturating_sub(1) + }; + if seg_count == 0 { + if (pline.start_width - pline.end_width).abs() < 1e-6 { + return Some(pline.start_width); + } else { + return None; + } + } + let (w0_start, w0_end) = + polyline2d_vertex_segment_widths(&verts[0], pline.start_width, pline.end_width); + if (w0_start - w0_end).abs() > 1e-6 { + return None; + } + let w0 = w0_start; + for i in 1..seg_count { + let (sw, ew) = + polyline2d_vertex_segment_widths(&verts[i], pline.start_width, pline.end_width); + if (sw - ew).abs() > 1e-6 || (sw - w0).abs() > 1e-6 { + return None; + } + } + Some(w0) +} + impl PropertyEditable for Polyline2D { fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { let n = self.vertices.len(); @@ -631,14 +676,24 @@ impl PropertyEditable for Polyline2D { let v = self.vertices.get(vi); let vertex_x = v.map(|v| v.location.x).unwrap_or_default(); let vertex_y = v.map(|v| v.location.y).unwrap_or_default(); - let seg_start_w = v.map(|v| v.start_width).unwrap_or_default(); - let seg_end_w = v.map(|v| v.end_width).unwrap_or_default(); + let (seg_start_w, seg_end_w) = v.map_or((0.0, 0.0), |v| { + polyline2d_vertex_segment_widths(v, self.start_width, self.end_width) + }); let vertex_label = if n == 0 { "—".to_string() } else { format!("{} / {}", vi + 1, n) }; + let global_width_prop = match polyline2d_global_width(self) { + Some(gw) => edit(t!("Global width").as_ref(), "pl2_start_w", gw), + None => Property { + label: t!("Global width").into_owned(), + field: "pl2_start_w", + value: PropValue::EditText(VARIES_LABEL.to_string()), + }, + }; + vec![ PropSection { title: t!("Geometry").into_owned(), @@ -648,7 +703,7 @@ impl PropertyEditable for Polyline2D { edit(t!("Vertex Y").as_ref(), "pl2_vertex_y", vertex_y), edit(t!("Start segment width").as_ref(), "pl2_seg_start_w", seg_start_w), edit(t!("End segment width").as_ref(), "pl2_seg_end_w", seg_end_w), - edit(t!("Global width").as_ref(), "pl2_start_w", self.start_width), + global_width_prop, edit(t!("Elevation").as_ref(), "pl2_elevation", self.elevation), ro(t!("Area").as_ref(), "pl2_area", format!("{area:.4}")), ro(t!("Length").as_ref(), "pl2_length", format!("{length:.4}")), @@ -706,9 +761,13 @@ impl PropertyEditable for Polyline2D { } "pl2_start_w" => { if let Some(v) = parse_f64(value) { - if v >= 0.0 { + if v.is_finite() && v >= 0.0 { self.start_width = v; self.end_width = v; + for vtx in &mut self.vertices { + vtx.start_width = v; + vtx.end_width = v; + } } } } @@ -723,16 +782,44 @@ impl PropertyEditable for Polyline2D { } } "pl2_seg_start_w" => { - if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.get_mut(vi)) { - if v >= 0.0 { - vert.start_width = v; + if let Some(v) = parse_f64(value) { + if v.is_finite() && v >= 0.0 { + if self.start_width > 1e-9 || self.end_width > 1e-9 { + let def_s = self.start_width; + let def_e = self.end_width; + for vtx in &mut self.vertices { + if vtx.start_width <= 1e-9 && vtx.end_width <= 1e-9 { + vtx.start_width = def_s; + vtx.end_width = def_e; + } + } + self.start_width = 0.0; + self.end_width = 0.0; + } + if let Some(vert) = self.vertices.get_mut(vi) { + vert.start_width = v; + } } } } "pl2_seg_end_w" => { - if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.get_mut(vi)) { - if v >= 0.0 { - vert.end_width = v; + if let Some(v) = parse_f64(value) { + if v.is_finite() && v >= 0.0 { + if self.start_width > 1e-9 || self.end_width > 1e-9 { + let def_s = self.start_width; + let def_e = self.end_width; + for vtx in &mut self.vertices { + if vtx.start_width <= 1e-9 && vtx.end_width <= 1e-9 { + vtx.start_width = def_s; + vtx.end_width = def_e; + } + } + self.start_width = 0.0; + self.end_width = 0.0; + } + if let Some(vert) = self.vertices.get_mut(vi) { + vert.end_width = v; + } } } } @@ -1331,3 +1418,84 @@ pub(crate) fn wide_fills(pl: &acadrust::entities::Polyline2D) -> ([f64; 2], Vec< } (origin, out) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::entities::traits::PropertyEditable; + use acadrust::entities::{Polyline2D, Vertex2D}; + use acadrust::Vector3; + + fn make_test_polyline2d(count: usize, start_w: f64, end_w: f64) -> Polyline2D { + let mut pl = Polyline2D::default(); + pl.start_width = start_w; + pl.end_width = end_w; + for i in 0..count { + pl.vertices.push(Vertex2D::new(Vector3::new(i as f64 * 10.0, 0.0, 0.0))); + } + pl + } + + #[test] + fn test_polyline2d_uniform_width_shows_global_width() { + let pl = make_test_polyline2d(3, 4.0, 4.0); + assert_eq!(polyline2d_global_width(&pl), Some(4.0)); + + let props = pl.geometry_properties(&[]); + let geom_props = &props[0].props; + let gw = geom_props.iter().find(|p| p.field == "pl2_start_w").unwrap(); + match &gw.value { + PropValue::EditText(val) => assert_eq!(parse_f64(val), Some(4.0)), + _ => panic!("expected EditText"), + } + } + + #[test] + fn test_polyline2d_varying_vertex_width_shows_varies() { + let mut pl = make_test_polyline2d(3, 4.0, 4.0); + pl.vertices[0].start_width = 1.0; + pl.vertices[0].end_width = 2.0; + + assert_eq!(polyline2d_global_width(&pl), None); + + let props = pl.geometry_properties(&[]); + let geom_props = &props[0].props; + let gw = geom_props.iter().find(|p| p.field == "pl2_start_w").unwrap(); + match &gw.value { + PropValue::EditText(val) => assert_eq!(val, VARIES_LABEL), + _ => panic!("expected EditText with VARIES_LABEL"), + } + } + + #[test] + fn test_polyline2d_modifying_global_width_sets_all_vertex_widths() { + let mut pl = make_test_polyline2d(3, 4.0, 4.0); + pl.vertices[0].start_width = 1.0; + pl.vertices[0].end_width = 2.0; + + pl.apply_geom_prop("pl2_start_w", "8.0"); + + assert_eq!(pl.start_width, 8.0); + assert_eq!(pl.end_width, 8.0); + for v in &pl.vertices { + assert_eq!(v.start_width, 8.0); + assert_eq!(v.end_width, 8.0); + } + assert_eq!(polyline2d_global_width(&pl), Some(8.0)); + } + + #[test] + fn test_polyline2d_modifying_segment_width_materializes_default_width() { + let mut pl = make_test_polyline2d(3, 4.0, 4.0); + assert_eq!(pl.vertices[0].start_width, 0.0); + + pl.apply_geom_prop("pl2_seg_start_w", "1.5"); + + assert_eq!(pl.vertices[0].start_width, 1.5); + assert_eq!(pl.vertices[0].end_width, 4.0); + assert_eq!(pl.vertices[1].start_width, 4.0); + assert_eq!(pl.vertices[1].end_width, 4.0); + + assert_eq!(polyline2d_global_width(&pl), None); + } +} diff --git a/src/entities/solid3d.rs b/src/entities/solid3d.rs index ce06f0b5a..8cf4f1924 100644 --- a/src/entities/solid3d.rs +++ b/src/entities/solid3d.rs @@ -3,6 +3,7 @@ // Shared grips and properties for modeler entities. use acadrust::entities::{Body, Region, Solid3D, Surface}; +use acadrust::xdata::{ExtendedDataRecord, XDataValue}; use cadkernel::space::polygon; use crate::t; use crate::command::EntityTransform; @@ -49,6 +50,71 @@ fn yes_no(value: bool) -> &'static str { if value { "Yes" } else { "No" } } +pub(crate) const SURFACE_PROPERTIES_APP: &str = "OCS_SURFACE_PROPERTIES"; + +#[derive(Clone, Copy)] +pub(crate) struct SurfacePropertyState { + pub isolines: bool, + pub maintain_associativity: bool, + pub show_associativity: bool, +} + +pub(crate) fn surface_property_state(surface: &Surface) -> SurfacePropertyState { + let defaults = SurfacePropertyState { + isolines: true, + maintain_associativity: surface.history_handle.is_some(), + show_associativity: false, + }; + let Some(record) = surface + .common + .extended_data + .get_record(SURFACE_PROPERTIES_APP) + else { + return defaults; + }; + let mut values = record.values.iter().filter_map(|value| match value { + XDataValue::Integer16(value) => Some(*value), + _ => None, + }); + let Some(version) = values.next() else { + return defaults; + }; + if version != 1 { + return defaults; + } + SurfacePropertyState { + isolines: values.next().map_or(defaults.isolines, |value| value == 0), + maintain_associativity: values + .next() + .map_or(defaults.maintain_associativity, |value| value != 0), + show_associativity: values + .next() + .map_or(defaults.show_associativity, |value| value != 0), + } +} + +pub(crate) fn surface_property_xdata_values(state: SurfacePropertyState) -> Vec { + vec![ + XDataValue::Integer16(1), + XDataValue::Integer16(if state.isolines { 0 } else { 1 }), + XDataValue::Integer16(state.maintain_associativity as i16), + XDataValue::Integer16(state.show_associativity as i16), + ] +} + +fn write_surface_property_state(surface: &mut Surface, state: SurfacePropertyState) { + let mut record = ExtendedDataRecord::new(SURFACE_PROPERTIES_APP); + record.values = surface_property_xdata_values(state); + surface.common.extended_data.upsert_record(record); +} + +pub(crate) fn surface_isoline_counts(surface: &Surface) -> [usize; 2] { + [ + surface.u_isolines.max(0) as usize, + surface.v_isolines.max(0) as usize, + ] +} + fn handle_text(handle: Option) -> String { handle .filter(|handle| handle.is_valid()) @@ -700,6 +766,21 @@ impl PropertyEditable for Surface { fn apply_geom_prop(&mut self, field: &str, value: &str) { match field { + "srf_wireframe_type" => { + let mut state = surface_property_state(self); + state.isolines = !value.eq_ignore_ascii_case("Isoparms"); + write_surface_property_state(self, state); + } + "srf_maintain_associativity" => { + let mut state = surface_property_state(self); + state.maintain_associativity = value.eq_ignore_ascii_case("Yes"); + write_surface_property_state(self, state); + } + "srf_show_associativity" => { + let mut state = surface_property_state(self); + state.show_associativity = value.eq_ignore_ascii_case("Yes"); + write_surface_property_state(self, state); + } "srf_u_isolines" => { if let Some(value) = parse_f64(value) { self.u_isolines = (value.round() as i16).max(0); @@ -822,7 +903,8 @@ pub fn tessellate_volume( color, facet_res, chordal_deflection, - isolines, + surface_isoline_counts(s), + surface_property_state(s).isolines, ), EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) => { crate::entities::mesh::tessellate_shaded_mesh(e, color) diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 1e09a7e20..700d2f68c 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -4120,6 +4120,71 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Pick box size" => Some(("common", "pick-box-size")), "Controls both the visible selection box and the click aperture." => Some(("common", "controls-both-the-visible-selection-box-and-the-click-aperture")), "Visual Effect Settings" => Some(("common", "visual-effect-settings")), + "Applications" => Some(("common", "applications")), + "Installed plugins and their sources" => Some(("common", "installed-plugins-and-their-sources")), + "Plugins…" => Some(("common", "plugins-2")), + "Keyboard shortcuts" => Some(("common", "keyboard-shortcuts-2")), + "Keyboard Shortcuts…" => Some(("common", "keyboard-shortcuts-3")), + "Command aliases" => Some(("common", "command-aliases-2")), + "Command Aliases…" => Some(("common", "command-aliases-3")), + "File Safety" => Some(("common", "file-safety")), + "Automatic save" => Some(("common", "automatic-save")), + "Minutes between recovery saves to a .sv$ file; 0 turns it off (SAVETIME)." => Some(("common", "minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime")), + "Keep a .bak copy when overwriting a drawing (ISAVEBAK)" => Some(("common", "keep-a-bak-copy-when-overwriting-a-drawing-isavebak")), + "Plotting" => Some(("common", "plotting")), + "Plot device, paper, scale and plot styles" => Some(("common", "plot-device-paper-scale-and-plot-styles")), + "Plot and Page Setup…" => Some(("common", "plot-and-page-setup")), + "Fill TrueType glyphs (TEXTFILL)" => Some(("common", "fill-truetype-glyphs-textfill")), + "Command Line" => Some(("common", "command-line")), + "Prompt lines" => Some(("common", "prompt-lines")), + "Temporary prompt lines shown above the command window (CLIPROMPTLINES)." => Some(("common", "temporary-prompt-lines-shown-above-the-command-window-clipromptlines")), + "History fade time" => Some(("common", "history-fade-time")), + "How long overlay history lines stay visible; 0 skips them (COMMANDLINEFADETIME)." => Some(("common", "how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime")), + "Clicking overlapping objects opens a picker" => Some(("common", "clicking-overlapping-objects-opens-a-picker")), + "Block Edit" => Some(("common", "block-edit")), + "Double-click to edit block in-place (REFEDIT)" => Some(("common", "double-click-to-edit-block-in-place-refedit")), + "Double-click attributed blocks to edit attributes (ATTEDIT)" => Some(("common", "double-click-attributed-blocks-to-edit-attributes-attedit")), + "User Preferences" => Some(("common", "user-preferences")), + "Zoom" => Some(("common", "zoom")), + "Reverse mouse wheel zoom (ZOOMWHEEL)" => Some(("common", "reverse-mouse-wheel-zoom-zoomwheel")), + "Zoom factor" => Some(("common", "zoom-factor")), + "How far one wheel notch zooms (ZOOMFACTOR)." => Some(("common", "how-far-one-wheel-notch-zooms-zoomfactor")), + "Text and Dimensions" => Some(("common", "text-and-dimensions")), + "TEXTEDIT edits one object and ends (TEXTEDITMODE)" => Some(("common", "textedit-edits-one-object-and-ends-texteditmode")), + "Continued dimensions inherit the base dimension's layer and style (DIMCONTINUEMODE)" => Some(("common", "continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode")), + "QDIM origin priority" => Some(("common", "qdim-origin-priority")), + "Which points QDIM measures from. Also settable inside the command." => Some(("common", "which-points-qdim-measures-from-also-settable-inside-the-command")), + "Add scales automatically" => Some(("common", "add-scales-automatically")), + "Which annotative objects pick up a newly set annotation scale (ANNOAUTOSCALE)." => Some(("common", "which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale")), + "Length, angle and insertion units" => Some(("common", "length-angle-and-insertion-units")), + "Drawing Units…" => Some(("common", "drawing-units-2")), + "Drafting" => Some(("common", "drafting")), + "Drafting rotation" => Some(("common", "drafting-rotation")), + "Rotates the crosshair and the snap grid in the active UCS (SNAPANG)." => Some(("common", "rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang")), + "Polar tracking increment" => Some(("common", "polar-tracking-increment")), + "Snap and Grid" => Some(("common", "snap-and-grid")), + "Snap modes, grid spacing and object snap" => Some(("common", "snap-modes-grid-spacing-and-object-snap")), + "Drafting Settings…" => Some(("common", "drafting-settings-2")), + "3D Modeling" => Some(("common", "modeling-3d")), + "Display Tools" => Some(("common", "display-tools")), + "Show the navigation cube (NAVVCUBE)" => Some(("common", "show-the-navigation-cube-navvcube")), + "Show the UCS icon (UCSICON)" => Some(("common", "show-the-ucs-icon-ucsicon")), + "Place the UCS icon at the origin (UCSICON ORigin)" => Some(("common", "place-the-ucs-icon-at-the-origin-ucsicon-origin")), + "Display Resolution" => Some(("common", "display-resolution")), + "applies to the current drawing" => Some(("common", "applies-to-the-current-drawing")), + "Isolines per surface" => Some(("common", "isolines-per-surface")), + "Show silhouette edges on solids (DISPSILH)" => Some(("common", "show-silhouette-edges-on-solids-dispsilh")), + "Surface density U" => Some(("common", "surface-density-u")), + "Surface density V" => Some(("common", "surface-density-v")), + "Surface type" => Some(("common", "surface-type")), + "Record the history of composite solids (SOLIDHIST)" => Some(("common", "record-the-history-of-composite-solids-solidhist")), + "Show solid history" => Some(("common", "show-solid-history")), + "Not available" => Some(("common", "not-available")), + "Open folder" => Some(("common", "open-folder")), + "Where the application keeps its own files. These locations are fixed." => Some(("common", "where-the-application-keeps-its-own-files-these-locations-are-fixed")), + "Configuration" => Some(("common", "configuration")), + "Plot styles" => Some(("common", "plot-styles")), + "Autosave files" => Some(("common", "autosave-files")), "Selection Modes" => Some(("common", "selection-modes")), "Use Shift to add to selection (PICKADD)" => Some(("common", "use-shift-to-add-to-selection-pickadd")), "Press and drag draws a rectangle instead of a lasso (PICKDRAG)" => Some(("common", "press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag")), diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index 0b349b42e..6318fde7f 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -4,10 +4,10 @@ // Produces a Ray entity; repeats until Enter/Esc. // XLINE — infinite construction line: same two-click pattern, yields XLine. +use crate::t; use acadrust::entities::{Ray as RayEnt, XLine as XLineEnt}; use acadrust::types::Vector3; use acadrust::EntityType; -use crate::t; use crate::command::{CadCommand, CmdResult}; use crate::scene::model::wire_model::WireModel; @@ -61,9 +61,7 @@ impl CadCommand for RayCommand { Vector3::new(base.x, base.y, base.z), Vector3::new(dir_n.x, dir_n.y, dir_n.z), ); - // Stay active: new base = same base (can keep clicking through points) - // Actually AutoCAD prompts for new start after each ray — reset base. - self.base = None; + // Repeated through points share the original start point. CmdResult::CommitEntity(EntityType::Ray(ray)) } else { self.base = Some(pt); @@ -167,7 +165,6 @@ impl CadCommand for XLineCommand { Vector3::new(base.x, base.y, base.z), Vector3::new(dir_n.x, dir_n.y, dir_n.z), ); - self.base = None; CmdResult::CommitEntity(EntityType::XLine(xline)) } else { self.base = Some(pt); @@ -229,7 +226,34 @@ impl CadCommand for XLineCommand { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repeated_rays_and_xlines_keep_their_first_point() { + let base = DVec3::new(2.0, 3.0, 4.0); + let mut ray = RayCommand::new(); + let mut xline = XLineCommand::new(); + assert!(matches!(ray.on_point(base), CmdResult::NeedPoint)); + assert!(matches!(xline.on_point(base), CmdResult::NeedPoint)); + + for through in [DVec3::new(3.0, 3.0, 4.0), DVec3::new(2.0, 5.0, 4.0)] { + let CmdResult::CommitEntity(EntityType::Ray(entity)) = ray.on_point(through) else { + panic!("ray was not committed"); + }; + assert_eq!(entity.base_point, Vector3::new(base.x, base.y, base.z)); + + let CmdResult::CommitEntity(EntityType::XLine(entity)) = xline.on_point(through) else { + panic!("construction line was not committed"); + }; + assert_eq!(entity.base_point, Vector3::new(base.x, base.y, base.z)); + } + } +} // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["RAY"] }); // RayCommand -inventory::submit!(crate::command::CommandRegistration { names: &["CONSTRUCTIONLINE", "XLINE"] }); // XLineCommand +inventory::submit!(crate::command::CommandRegistration { names: &["RAY"] }); // RayCommand +inventory::submit!(crate::command::CommandRegistration { + names: &["CONSTRUCTIONLINE", "XLINE"] +}); // XLineCommand diff --git a/src/modules/draw/draw/revcloud.rs b/src/modules/draw/draw/revcloud.rs index 8da3960c7..516f0e23a 100644 --- a/src/modules/draw/draw/revcloud.rs +++ b/src/modules/draw/draw/revcloud.rs @@ -933,4 +933,6 @@ fn style_from_entity(entity: &EntityType) -> Option { }) } -inventory::submit!(crate::command::CommandRegistration { names: &["REVCLOUD"] }); +inventory::submit!(crate::command::CommandRegistration { + names: &["REVCLOUD", "REVCLOUD_RECTANGULAR", "REVCLOUD_POLYGONAL", "REVCLOUD_FREEHAND"] +}); diff --git a/src/modules/draw/draw/spline.rs b/src/modules/draw/draw/spline.rs index 97405c448..8ace3cb2d 100644 --- a/src/modules/draw/draw/spline.rs +++ b/src/modules/draw/draw/spline.rs @@ -3,9 +3,9 @@ // Command: SPLINE (SPL) // Click to add fit points. Enter (≥2 pts) → commits EntityType::Spline. +use crate::t; use acadrust::types::Vector3; use acadrust::{EntityType, Spline}; -use crate::t; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -24,49 +24,87 @@ pub fn tool() -> ToolDef { pub struct SplineCommand { pts: Vec, + control_vertices: bool, + choosing_method: bool, } impl SplineCommand { pub fn new() -> Self { - Self { pts: Vec::new() } + Self { + pts: Vec::new(), + control_vertices: false, + choosing_method: false, + } + } + + pub fn control_vertices() -> Self { + Self { + control_vertices: true, + ..Self::new() + } } fn build(&self, closed: bool) -> Option { if self.pts.len() < 2 { return None; } - let fit_points = self - .pts - .iter() - .map(|p| Vector3::new(p.x, p.y, p.z)) - .collect(); - let mut spline = Spline { - degree: 3, - fit_points, - ..Default::default() - }; - spline.flags.closed = closed; - Some(EntityType::Spline(spline)) + Some(EntityType::Spline(make_spline( + &self.pts, + closed, + self.control_vertices, + ))) } } -/// Preview through the same fit-point representation that `build()` commits. -fn sample_curve(pts: &[DVec3], closed: bool) -> Vec<[f32; 3]> { +/// 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() + } + }; + spline.flags.closed = 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 mut spline = Spline { - degree: 3, - fit_points: pts - .iter() - .map(|p| Vector3::new(p.x, p.y, p.z)) - .collect(), - ..Default::default() - }; - spline.flags.closed = closed; + 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)) @@ -77,11 +115,19 @@ fn sample_curve(pts: &[DVec3], closed: bool) -> Vec<[f32; 3]> { impl CadCommand for SplineCommand { fn name(&self) -> &'static str { - "SPLINE" + if self.control_vertices { + "SPLINECV" + } else { + "SPLINE" + } } fn prompt(&self) -> String { - if self.pts.is_empty() { + if self.choosing_method { + "SPLINE Choose creation method [Fit/Control vertices]:".into() + } else if self.pts.is_empty() && self.control_vertices { + t!("SPLINE Specify first control point:").into_owned() + } else if self.pts.is_empty() { t!("SPLINE Specify first point:").into_owned() } else { let n = self.pts.len(); @@ -91,8 +137,14 @@ impl CadCommand for SplineCommand { fn options(&self) -> Vec { use crate::command::CmdOption; + if self.choosing_method { + return vec![ + CmdOption::new("Fit", "FIT"), + CmdOption::new("Control vertices", "CV"), + ]; + } if self.pts.is_empty() { - return Vec::new(); + return vec![CmdOption::new(t!("Method").as_ref(), "M")]; } let mut opts = vec![CmdOption::new(t!("Close").as_ref(), "C")]; // Undo only makes sense once a control point exists. @@ -102,11 +154,18 @@ impl CadCommand for SplineCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + if self.choosing_method || !pt.is_finite() { + return CmdResult::NeedPoint; + } self.pts.push(pt); CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { + if self.choosing_method { + self.choosing_method = false; + return CmdResult::NeedPoint; + } match self.build(false) { Some(e) => CmdResult::CommitAndExit(e), None => CmdResult::Cancel, @@ -114,26 +173,37 @@ impl CadCommand for SplineCommand { } fn enter_accepts_default_start(&self) -> bool { - self.pts.is_empty() + self.pts.is_empty() && !self.choosing_method } fn on_escape(&mut self) -> CmdResult { - match self.build(false) { - Some(e) => CmdResult::CommitAndExit(e), - None => CmdResult::Cancel, - } + CmdResult::Cancel } fn wants_text_input(&self) -> bool { - !self.pts.is_empty() + true } fn point_step_accepts_keywords(&self) -> bool { - !self.pts.is_empty() + true } fn on_text_input(&mut self, text: &str) -> Option { match text.trim().to_uppercase().as_str() { + "M" | "METHOD" if self.pts.is_empty() => { + self.choosing_method = true; + Some(CmdResult::NeedPoint) + } + "F" | "FIT" if self.pts.is_empty() => { + self.control_vertices = false; + self.choosing_method = false; + Some(CmdResult::NeedPoint) + } + "CV" | "CONTROL" | "CONTROLVERTICES" if self.pts.is_empty() => { + self.control_vertices = true; + self.choosing_method = false; + Some(CmdResult::NeedPoint) + } "C" | "CLOSE" => match self.build(true) { Some(e) => Some(CmdResult::CommitAndExit(e)), None => Some(CmdResult::NeedPoint), @@ -150,18 +220,44 @@ impl CadCommand for SplineCommand { if self.pts.is_empty() { return None; } - // Preview the committed fit-point representation. + // Preview the committed construction method. let mut ctrl = self.pts.clone(); ctrl.push(pt); Some(WireModel::solid( "rubber_band".into(), - sample_curve(&ctrl, false), + sample_curve(&ctrl, false, self.control_vertices), WireModel::CYAN, false, )) } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn control_vertices_make_a_clamped_finite_spline() { + let points = [ + DVec3::new(0.0, 0.0, 0.0), + DVec3::new(1.0, 2.0, 0.0), + DVec3::new(2.0, 2.0, 0.0), + DVec3::new(3.0, 0.0, 0.0), + ]; + let spline = make_spline(&points, false, true); + + 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())); + } +} // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["SPLINE"] }); // SplineCommand +inventory::submit!(crate::command::CommandRegistration { + names: &["SPLINE", "SPLINECV"] +}); // SplineCommand diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 3f5788237..5e4d5d129 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -2778,6 +2778,8 @@ pub fn empty_extruded_surface(direction: DVec3, taper_angle: f64) -> EntityType use acadrust::types::Vector3; let mut surface = Surface::new(SurfaceKind::Extruded); + surface.u_isolines = 6; + surface.v_isolines = 6; if let SurfaceData::Extruded { options, sweep_vector, diff --git a/src/modules/manage/mod.rs b/src/modules/manage/mod.rs index f1a66e7aa..48fda655e 100644 --- a/src/modules/manage/mod.rs +++ b/src/modules/manage/mod.rs @@ -79,7 +79,13 @@ impl CadModule for ManageModule { // ── Application ─────────────────────────────────────────────────── RibbonGroup { title: "Application", - tools: vec![RibbonItem::LargeTool(about::tool())], + tools: vec![ + // The tool and its icon already existed but were never + // added to a group, so the dialog could only be reached + // by typing OPTIONS or from the start page. + RibbonItem::LargeTool(options::tool()), + RibbonItem::LargeTool(about::tool()), + ], }, ] }) diff --git a/src/modules/manage/options.rs b/src/modules/manage/options.rs index 615f31d0a..75bea9afc 100644 --- a/src/modules/manage/options.rs +++ b/src/modules/manage/options.rs @@ -1,5 +1,4 @@ use crate::modules::{IconKind, ModuleEvent, ToolDef}; -#[allow(dead_code)] pub fn tool() -> ToolDef { ToolDef { id: "OPTIONS", diff --git a/src/modules/model/edge_cmd.rs b/src/modules/model/edge_cmd.rs index ae7cb1303..08dfda16e 100644 --- a/src/modules/model/edge_cmd.rs +++ b/src/modules/model/edge_cmd.rs @@ -210,7 +210,12 @@ impl SolidEdgeCommand { ) .ok()?, }; - let mut wires = crate::scene::model::solid_model::grip_preview_wires(&result, handle); + let mut wires = crate::scene::model::solid_model::grip_preview_wires( + &result, + handle, + [0; 2], + false, + ); for wire in &mut wires { wire.color = self.preview_color; wire.name = format!("{}-{}-PREVIEW", handle.value(), self.name()); diff --git a/src/scene/convert/acis_kernel.rs b/src/scene/convert/acis_kernel.rs index c21c69364..29fb8b613 100644 --- a/src/scene/convert/acis_kernel.rs +++ b/src/scene/convert/acis_kernel.rs @@ -24,7 +24,8 @@ pub fn tessellate_sat( color: [f32; 4], facet_res: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], + planar_isolines: bool, ) -> Option { let (bodies, loss) = lift(document); if bodies.is_empty() { @@ -114,7 +115,8 @@ pub fn tessellate_sat( max_angle, source_fit * placement_scale, ) - .with_isolines(isolines); + .with_uv_isolines(isolines[0], isolines[1]) + .with_planar_isolines(planar_isolines); if let Some(deflection) = chordal_deflection { tolerance = tolerance.with_chordal_deflection(deflection); } diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 598627e8c..682bfd3c0 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -10,7 +10,8 @@ fn tessellate_acis( color: [f32; 4], facet_res: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], + planar_isolines: bool, ) -> Option { crate::scene::convert::acis_kernel::tessellate_sat( sat, @@ -19,6 +20,7 @@ fn tessellate_acis( facet_res, chordal_deflection, isolines, + planar_isolines, ) } @@ -218,7 +220,8 @@ fn finish( color: [f32; 4], facet_res: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], + planar_isolines: bool, acis: &acadrust::entities::AcisData, ) -> Option { let mut set = tessellate_acis( @@ -228,6 +231,7 @@ fn finish( facet_res, chordal_deflection, isolines, + planar_isolines, )?; remap_acis_material_bindings(&mut set, acis); Some(set) @@ -251,7 +255,8 @@ pub fn tessellate_region( color, facet_res, chordal_deflection, - isolines, + [isolines; 2], + false, ®ion.acis_data, ) } @@ -274,7 +279,8 @@ pub fn tessellate_body( color, facet_res, chordal_deflection, - isolines, + [isolines; 2], + false, &body.acis_data, ) } @@ -284,7 +290,8 @@ pub fn tessellate_surface( color: [f32; 4], facet_res: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], + planar_isolines: bool, ) -> Option { let sat = parse_acis( || surface.parse_sat(), @@ -298,6 +305,7 @@ pub fn tessellate_surface( facet_res, chordal_deflection, isolines, + planar_isolines, &surface.acis_data, ) } @@ -320,7 +328,8 @@ pub fn tessellate_solid3d( color, facet_res, chordal_deflection, - isolines, + [isolines; 2], + false, &solid.acis_data, ) } diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 8c9a22af5..829b20cf1 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -215,17 +215,12 @@ fn polyline_segment_widths(entity: &EntityType) -> Vec<(f32, f32)> { (0..seg_count) .map(|i| { let v = &p.vertices[i]; - let sw = if v.start_width > 1e-9 { - v.start_width + let (sw, ew) = if v.start_width > 1e-9 || v.end_width > 1e-9 { + (v.start_width, v.end_width) } else { - c - } as f32; - let ew = if v.end_width > 1e-9 { - v.end_width - } else { - c - } as f32; - (sw, ew) + (c, c) + }; + (sw as f32, ew as f32) }) .collect() } @@ -244,17 +239,12 @@ fn polyline_segment_widths(entity: &EntityType) -> Vec<(f32, f32)> { (0..seg_count) .map(|i| { let v = &verts[i]; - let sw = if v.start_width > 1e-9 { - v.start_width + let (sw, ew) = if v.start_width > 1e-9 || v.end_width > 1e-9 { + (v.start_width, v.end_width) } else { - def_start - } as f32; - let ew = if v.end_width > 1e-9 { - v.end_width - } else { - def_end - } as f32; - (sw, ew) + (def_start, def_end) + }; + (sw as f32, ew as f32) }) .collect() } @@ -292,7 +282,53 @@ fn split_mixed_polyline( end_angle, } = *tg { + let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) { + (w0, w1) + } else if global_world_width > 1e-9 { + (global_world_width, global_world_width) + } else { + (0.0, 0.0) + }; + + // Check if the CCW start_angle of this TangentGeom::Arc corresponds + // to the segment's end vertex (clockwise bulge arc in CAD where + // bulge_arc_to_tangent inverted start_angle and end_angle to enforce CCW). + let (p_seg_start, p_seg_end) = if i + 1 < key_vertices.len() { + (key_vertices[i], key_vertices[i + 1]) + } else if !key_vertices.is_empty() { + ( + key_vertices[i % key_vertices.len()], + key_vertices[(i + 1) % key_vertices.len()], + ) + } else { + ([0.0; 3], [0.0; 3]) + }; + + let p_sa = [ + center[0] + radius * (start_angle.cos() * axis_x[0] + start_angle.sin() * axis_y[0]), + center[1] + radius * (start_angle.cos() * axis_x[1] + start_angle.sin() * axis_y[1]), + center[2] + radius * (start_angle.cos() * axis_x[2] + start_angle.sin() * axis_y[2]), + ]; + let is_reversed = if !key_vertices.is_empty() { + let d_start_sq = (p_sa[0] - p_seg_start[0]).powi(2) + + (p_sa[1] - p_seg_start[1]).powi(2) + + (p_sa[2] - p_seg_start[2]).powi(2); + let d_end_sq = (p_sa[0] - p_seg_end[0]).powi(2) + + (p_sa[1] - p_seg_end[1]).powi(2) + + (p_sa[2] - p_seg_end[2]).powi(2); + d_end_sq < d_start_sq + } else { + false + }; + + let (w_at_sa, w_at_ea) = if is_reversed { + (ew, sw) + } else { + (sw, ew) + }; + let mut arc_pts = Vec::with_capacity(17); + let mut arc_widths = Vec::with_capacity(17); let n = 16; let sweep = if end_angle >= start_angle { end_angle - start_angle @@ -308,25 +344,12 @@ fn split_mixed_polyline( center[2] + radius * (ang.cos() * axis_x[2] + ang.sin() * axis_y[2]), ]; arc_pts.push(p); + arc_widths.push(w_at_sa + (w_at_ea - w_at_sa) * frac as f32); } let (points, points_low) = points_to_ds(arc_pts); - let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) { - if w0 > 1e-9 || w1 > 1e-9 { - (w0, w1) - } else if global_world_width > 1e-9 { - (global_world_width, global_world_width) - } else { - (0.0, 0.0) - } - } else if global_world_width > 1e-9 { - (global_world_width, global_world_width) - } else { - (0.0, 0.0) - }; - - let taper_widths = if (sw - ew).abs() > 1e-6 { - vec![sw, ew] + let taper_widths = if (w_at_sa - w_at_ea).abs() > 1e-6 { + arc_widths } else { Vec::new() }; @@ -377,43 +400,77 @@ fn split_mixed_polyline( // Collect straight lines into a line wire let mut straight_pts: Vec<[f64; 3]> = Vec::new(); + let mut straight_widths: Vec = Vec::new(); let mut straight_tangents: Vec = Vec::new(); let mut last_end: Option<[f64; 3]> = None; + let mut last_end_w: f32 = 0.0; for (i, tg) in tangent_geoms.iter().enumerate() { if let TangentGeom::Line { p1, p2 } = tg { straight_tangents.push(tg.clone()); - let (p_start, p_end) = if i + 1 < key_vertices.len() { - (key_vertices[i], key_vertices[i + 1]) + let p_start = if i + 1 < key_vertices.len() + && (key_vertices[i][0] as f32 - p1[0]).abs() < 1e-3 + && (key_vertices[i][1] as f32 - p1[1]).abs() < 1e-3 + && (key_vertices[i][2] as f32 - p1[2]).abs() < 1e-3 + { + key_vertices[i] } else { - ( - [p1[0] as f64, p1[1] as f64, p1[2] as f64], - [p2[0] as f64, p2[1] as f64, p2[2] as f64], - ) + [p1[0] as f64, p1[1] as f64, p1[2] as f64] + }; + let p_end = if i + 1 < key_vertices.len() + && (key_vertices[i + 1][0] as f32 - p2[0]).abs() < 1e-3 + && (key_vertices[i + 1][1] as f32 - p2[1]).abs() < 1e-3 + && (key_vertices[i + 1][2] as f32 - p2[2]).abs() < 1e-3 + { + key_vertices[i + 1] + } else { + [p2[0] as f64, p2[1] as f64, p2[2] as f64] + }; + let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) { + (w0, w1) + } else if global_world_width > 1e-9 { + (global_world_width, global_world_width) + } else { + (0.0, 0.0) }; if !plinegen { if !straight_pts.is_empty() { straight_pts.push([f64::NAN; 3]); + straight_widths.push(0.0); } straight_pts.push(p_start); + straight_widths.push(sw); straight_pts.push(p_end); + straight_widths.push(ew); } else { if let Some(prev) = last_end { if (prev[0] - p_start[0]).abs() < 1e-7 && (prev[1] - p_start[1]).abs() < 1e-7 && (prev[2] - p_start[2]).abs() < 1e-7 + && (last_end_w - sw).abs() < 1e-6 { straight_pts.push(p_end); + straight_widths.push(ew); } else { straight_pts.push([f64::NAN; 3]); + straight_widths.push(0.0); straight_pts.push(p_start); + straight_widths.push(sw); straight_pts.push(p_end); + straight_widths.push(ew); } } else { + if !straight_pts.is_empty() { + straight_pts.push([f64::NAN; 3]); + straight_widths.push(0.0); + } straight_pts.push(p_start); + straight_widths.push(sw); straight_pts.push(p_end); + straight_widths.push(ew); } last_end = Some(p_end); + last_end_w = ew; } } else { last_end = None; @@ -427,12 +484,24 @@ fn split_mixed_polyline( } else { (Vec::new(), Vec::new()) }; + let line_world_width = straight_widths + .iter() + .copied() + .fold(0.0f32, f32::max); + let has_line_taper = straight_widths + .first() + .map_or(false, |&w0| straight_widths.iter().any(|&w| (w - w0).abs() > 1e-6)); + let taper_widths = if has_line_taper || straight_widths.iter().any(|&w| w > 1e-6) { + straight_widths + } else { + Vec::new() + }; out.push(WireModel { bg_adapt: None, point_marker, - taper_widths: Vec::new(), + taper_widths, pattern_stations: Vec::new(), - world_width: global_world_width, + world_width: line_world_width, depth_override: None, display_visible: true, plot_visible: true, @@ -2989,3 +3058,176 @@ pub(crate) fn normalized_or(v: Vec3, fallback: Vec3) -> Vec3 { v.normalize() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_mixed_polyline_straight_lines_have_taper_widths() { + let tg_line = TangentGeom::Line { + p1: [0.0, 0.0, 0.0], + p2: [10.0, 0.0, 0.0], + }; + let tg_arc = TangentGeom::Arc { + center: [10.0, 5.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + start_angle: -std::f64::consts::FRAC_PI_2, + end_angle: 0.0, + }; + let tgs = vec![tg_line, tg_arc]; + let kv = vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [15.0, 5.0, 0.0]]; + let seg_widths = vec![(0.0f32, 10.0f32), (10.0f32, 20.0f32)]; + + let wires = split_mixed_polyline( + &tgs, + &kv, + "mixed", + [1.0, 1.0, 1.0, 1.0], + false, + 0.0, + [0.0; 8], + 1.0, + Vec::new(), + None, + true, + &seg_widths, + 20.0, + Vec::new(), + Vec::new(), + ); + + // Arc wire + Line wire + assert_eq!(wires.len(), 2); + let arc_wire = &wires[0]; + assert_eq!(arc_wire.taper_widths.first().copied(), Some(10.0)); + assert_eq!(arc_wire.taper_widths.last().copied(), Some(20.0)); + + let line_wire = &wires[1]; + assert_eq!(line_wire.taper_widths, vec![0.0, 10.0]); + assert_eq!(line_wire.world_width, 10.0); + } + + #[test] + fn test_split_mixed_polyline_zero_width_segments_after_tapered_segment() { + let tg_line = TangentGeom::Line { + p1: [0.0, 0.0, 0.0], + p2: [10.0, 0.0, 0.0], + }; + let tg_arc1 = TangentGeom::Arc { + center: [10.0, 5.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + start_angle: -std::f64::consts::FRAC_PI_2, + end_angle: 0.0, + }; + let tg_arc2 = TangentGeom::Arc { + center: [15.0, 10.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + start_angle: -std::f64::consts::FRAC_PI_2, + end_angle: 0.0, + }; + let tgs = vec![tg_line, tg_arc1, tg_arc2]; + let kv = vec![ + [0.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + [15.0, 5.0, 0.0], + [20.0, 10.0, 0.0], + ]; + // Segment 0 has width (2.0, 0.0), segment 1 and 2 have width (0.0, 0.0) + let seg_widths = vec![(2.0f32, 0.0f32), (0.0f32, 0.0f32), (0.0f32, 0.0f32)]; + + let wires = split_mixed_polyline( + &tgs, + &kv, + "taper_then_zero", + [1.0, 1.0, 1.0, 1.0], + false, + 0.0, + [0.0; 8], + 1.0, + Vec::new(), + None, + true, + &seg_widths, + 2.0, + Vec::new(), + Vec::new(), + ); + + // 2 arc wires + 1 line wire = 3 wires + assert_eq!(wires.len(), 3); + let arc1 = &wires[0]; + let arc2 = &wires[1]; + let line = &wires[2]; + + // Arcs must have world_width = 0.0 and empty taper_widths + assert_eq!(arc1.world_width, 0.0); + assert!(arc1.taper_widths.is_empty()); + assert_eq!(arc2.world_width, 0.0); + assert!(arc2.taper_widths.is_empty()); + + let inst1 = crate::scene::pipeline::circle_gpu::extract_circle_instance(arc1, 0.0) + .expect("arc1 instance"); + assert_eq!(inst1.start_width, 0.0); + assert_eq!(inst1.params[3], 0.0); + + // Straight line must have world_width = 2.0 and taper_widths = [2.0, 0.0] + assert_eq!(line.world_width, 2.0); + assert_eq!(line.taper_widths, vec![2.0, 0.0]); + } + + #[test] + fn test_split_mixed_polyline_reversed_arc_aligns_endpoints() { + // Clockwise arc from (10.0, 0.0) to (15.0, 5.0). + // bulge_arc_to_tangent inverts angles so start_angle is at (15.0, 5.0) and end_angle at (10.0, 0.0). + let tg_arc_reversed = TangentGeom::Arc { + center: [10.0, 5.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + start_angle: 0.0, // at (15.0, 5.0) - physical end! + end_angle: -std::f64::consts::FRAC_PI_2, // at (10.0, 0.0) - physical start! + }; + let tgs = vec![tg_arc_reversed]; + let kv = vec![[10.0, 0.0, 0.0], [15.0, 5.0, 0.0]]; + let seg_widths = vec![(5.0f32, 25.0f32)]; // sw=5.0 at start, ew=25.0 at end + + let wires = split_mixed_polyline( + &tgs, + &kv, + "arc_rev", + [1.0, 1.0, 1.0, 1.0], + false, + 0.0, + [0.0; 8], + 1.0, + Vec::new(), + None, + true, + &seg_widths, + 25.0, + Vec::new(), + Vec::new(), + ); + + assert_eq!(wires.len(), 1); + let arc_wire = &wires[0]; + // In CCW shader angles, start_angle is at (15.0, 5.0) which is physical end (ew = 25.0). + // end_angle is at (10.0, 0.0) which is physical start (sw = 5.0). + assert_eq!(arc_wire.taper_widths.first().copied(), Some(25.0)); + assert_eq!(arc_wire.taper_widths.last().copied(), Some(5.0)); + + let inst = crate::scene::pipeline::circle_gpu::extract_circle_instance(arc_wire, 0.0) + .expect("arc instance extracted"); + // start_width is at start_angle (physical end, 25.0) + assert_eq!(inst.start_width, 25.0); + // params.w (end_width) is at end_angle (physical start, 5.0) + assert_eq!(inst.params[3], 5.0); + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 0ee848dd1..2b881a485 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3159,7 +3159,13 @@ impl Scene { let facet_resolution = self.document.header.facet_resolution; let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_resolution); - let isolines = self.document.header.isolines.max(0) as usize; + let (isolines, planar_isolines) = match self.document.get_entity(handle) { + Some(EntityType::Surface(surface)) => ( + crate::entities::solid3d::surface_isoline_counts(surface), + crate::entities::solid3d::surface_property_state(surface).isolines, + ), + _ => ([self.document.header.isolines.max(0) as usize; 2], false), + }; let (mut set, wires, center) = crate::scene::model::solid_model::display_from_solid( solid, @@ -3167,6 +3173,7 @@ impl Scene { facet_resolution, chordal_deflection, isolines, + planar_isolines, )?; let name = handle.value().to_string(); for mesh in &mut set.lods { @@ -5487,8 +5494,12 @@ impl Scene { // changed entities into it, instead of re-cloning + re-sorting the whole // set. Falls back to the full build below when it can't (no cache entry, // journal un-replayable, or a structural assumption violated). - if let Some(arc) = - self.try_resident_patch( + // A live section changes the displayed wires of every intersected + // solid, even when the delta journal contains only the section entity. + // Rebuild the resident set in that case so an incremental patch cannot + // retain the source solid's uncut edge cache. + if self.active_live_section(block).is_none() { + if let Some(arc) = self.try_resident_patch( key, block, bg, @@ -5497,9 +5508,9 @@ impl Scene { all_visible, frozen_layers, style_viewport, - ) - { - return arc; + ) { + return arc; + } } // Build once: full tessellation, no cull, no zoom LOD — the resident // set is zoom-independent (GPU analytical circles/arcs/ellipses). @@ -7073,6 +7084,48 @@ impl Scene { Self::section_body(&body, §ion.data, section.slice_depth, None) } + /// Build a temporary entity whose wire data matches the live-section body. + /// The document entity stays unchanged so disabling the section restores + /// the complete source geometry and saving never persists the clipped body. + fn sectioned_wire_entity( + &self, + handle: Handle, + section: &LiveSection, + ) -> Result, ()> { + let Some(body) = self.sectioned_body(handle, section)? else { + return Ok(None); + }; + let (_, wires, center) = self + .prepare_solid_model_display(handle, &body) + .ok_or(())?; + let mut entity = self.document.get_entity(handle).cloned().ok_or(())?; + let reference = acadrust::types::Vector3::new(center[0], center[1], center[2]); + match &mut entity { + EntityType::Solid3D(solid) => { + solid.point_of_reference = reference; + solid.wires = wires; + solid.silhouettes.clear(); + } + EntityType::Surface(surface) => { + surface.point_of_reference = reference; + surface.wires = wires; + surface.silhouettes.clear(); + } + EntityType::Region(region) => { + region.point_of_reference = reference; + region.wires = wires; + region.silhouettes.clear(); + } + EntityType::Body(body) => { + body.point_of_reference = reference; + body.wires = wires; + body.silhouettes.clear(); + } + _ => return Err(()), + } + Ok(Some(entity)) + } + fn section_source_body( &self, handle: Handle, @@ -9786,6 +9839,45 @@ impl Scene { memo_misses = visible_count; out }; + + // Normal Wireframe renders the persisted entity wires, whereas shaded + // and 3D Wireframe modes render the temporary sectioned mesh. Replace + // only the displayed wire run with one tessellated from that same + // temporary body so every visual style shows the identical live cut. + if let Some(section) = self.active_live_section(block_handle) { + let handles: Vec = self.meshes.keys().copied().collect(); + for handle in handles { + let Some(source) = self.document.get_entity(handle) else { + continue; + }; + if !visibility_ok(source) { + continue; + } + let replacement = match self.sectioned_wire_entity(handle, §ion) { + Ok(Some(entity)) => Some(tessellate_entity( + doc, + sel, + avp, + bg, + anno, + annotation_scale_handle, + &entity, + Some(blk_ref), + view_aabb, + wpp, + paper, + )), + Ok(None) => Some(Vec::new()), + Err(()) => None, + }; + let Some(replacement) = replacement else { + continue; + }; + let name = handle.value().to_string(); + wires.retain(|wire| wire.name != name); + wires.extend(replacement); + } + } let build_ms = crate::perf::elapsed_ms(t_build); // Resolve display colours for the live background. Memo hits were diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 99bd2e6c3..5e7367141 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -120,6 +120,8 @@ pub enum GripShape { Circle, /// Screen-offset menu selector. Dropdown, + /// Menu selector placed immediately beside its anchor grip. + DropdownAdjacent, } /// Describes one grip point for an entity. @@ -202,6 +204,10 @@ pub enum GripMenuAction { RefineVertices, ShowFit, ShowControlVertices, + SectionPlane, + SectionSlice, + SectionBoundary, + SectionVolume, MoveWithText, StackText, UnstackText, diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index f9cc88bb6..45eafe9e7 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -94,6 +94,14 @@ pub const PROP_LOFT_CLOSED: &str = "solid_history_loft_closed"; pub const PROP_LOFT_PERIODIC: &str = "solid_history_loft_periodic"; pub const PROP_HISTORY: &str = "solid_history_record"; pub const PROP_SHOW_HISTORY: &str = "solid_history_show"; +pub const PROP_SURFACE_TYPE: &str = "surface_type"; +pub const PROP_SURFACE_WIREFRAME_TYPE: &str = "srf_wireframe_type"; +pub const PROP_SURFACE_U_ISOLINES: &str = "srf_u_isolines"; +pub const PROP_SURFACE_V_ISOLINES: &str = "srf_v_isolines"; +pub const PROP_SURFACE_MAINTAIN_ASSOCIATIVITY: &str = "srf_maintain_associativity"; +pub const PROP_SURFACE_SHOW_ASSOCIATIVITY: &str = "srf_show_associativity"; +pub const PROP_SURFACE_TRIMMED: &str = "srf_trimmed"; +pub const PROP_SURFACE_TRIMMING_EDGES: &str = "srf_trimming_edges"; fn history_prop(label: &str, field: &'static str, value: impl ToString) -> Property { Property { @@ -103,6 +111,23 @@ fn history_prop(label: &str, field: &'static str, value: impl ToString) -> Prope } } +fn compact_surface_number(mut value: String) -> String { + if value.ends_with('°') { + value.pop(); + } + if let Some(decimal) = value.rfind('.') { + if value[decimal + 1..].chars().all(|character| character.is_ascii_digit()) { + while value.ends_with('0') { + value.pop(); + } + if value.ends_with('.') { + value.pop(); + } + } + } + value +} + fn history_flags( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -239,9 +264,14 @@ pub fn has_compact_solid_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, ) -> bool { - document - .get_entity(handle) - .is_some_and(|entity| matches!(entity, acadrust::EntityType::Solid3D(_))) + match document.get_entity(handle) { + Some(acadrust::EntityType::Solid3D(_)) => true, + Some(acadrust::EntityType::Surface(_)) => matches!( + primitive_property_operation(document, handle), + Some(SolidHistoryOperation::Extrusion(_)) + ), + _ => false, + } } pub fn reference_point(operation: &SolidHistoryOperation) -> Option { @@ -403,10 +433,6 @@ fn extrusion_properties( |direction| crate::entities::common::format_length(direction[axis]), )), }; - let (record_history, object_show_history, show_history_mode) = - history_flags(document, handle).unwrap_or((false, false, 1)); - let (show_history, show_history_editable) = - displayed_history_state(object_show_history, show_history_mode); let path_length = sweep_path_length(value); let height_value = path_length.unwrap_or(value.end_draft_distance); let height = if value.path_entity.is_some() { @@ -419,6 +445,134 @@ fn extrusion_properties( } else { PropValue::EditText(crate::entities::common::format_angle(value.draft_angle)) }; + if let Some(EntityType::Surface(surface)) = document.get_entity(handle) { + let state = crate::entities::solid3d::surface_property_state(surface); + let surface_height = if value.path_entity.is_some() { + PropValue::ReadOnly(compact_surface_number( + crate::entities::common::format_length(height_value), + )) + } else { + PropValue::EditText(compact_surface_number( + crate::entities::common::format_length(height_value), + )) + }; + let surface_taper = if value.path_entity.is_some() { + PropValue::ReadOnly(compact_surface_number( + crate::entities::common::format_angle(value.draft_angle), + )) + } else { + PropValue::EditText(compact_surface_number( + crate::entities::common::format_angle(value.draft_angle), + )) + }; + let surface_direction_property = + |label: &str, field: &'static str, axis: usize| Property { + label: label.to_string(), + field, + value: PropValue::ReadOnly(direction.map_or_else( + || t!("Unavailable").into_owned(), + |direction| { + compact_surface_number(crate::entities::common::format_length( + direction[axis], + )) + }, + )), + }; + let yes_no_choice = |selected: bool| PropValue::Choice { + selected: if selected { "Yes" } else { "No" }.to_string(), + options: vec!["Yes".to_string(), "No".to_string()], + }; + return vec![ + PropSection { + title: t!("Geometry").into_owned(), + props: vec![ + Property { + label: t!("Surface Type").into_owned(), + field: PROP_SURFACE_TYPE, + value: PropValue::ReadOnly(t!("Extrusion").into_owned()), + }, + Property { + label: t!("Height").into_owned(), + field: PROP_EXTRUSION_HEIGHT, + value: surface_height, + }, + Property { + label: t!("Taper angle").into_owned(), + field: PROP_TAPER_ANGLE, + value: surface_taper, + }, + surface_direction_property( + t!("Direction X").as_ref(), + PROP_EXTRUSION_DIRECTION_X, + 0, + ), + surface_direction_property( + t!("Direction Y").as_ref(), + PROP_EXTRUSION_DIRECTION_Y, + 1, + ), + surface_direction_property( + t!("Direction Z").as_ref(), + PROP_EXTRUSION_DIRECTION_Z, + 2, + ), + Property { + label: t!("Wireframe type").into_owned(), + field: PROP_SURFACE_WIREFRAME_TYPE, + value: PropValue::Choice { + selected: if state.isolines { "Isolines" } else { "Isoparms" } + .to_string(), + options: vec!["Isolines".to_string(), "Isoparms".to_string()], + }, + }, + Property { + label: t!("U isolines").into_owned(), + field: PROP_SURFACE_U_ISOLINES, + value: PropValue::EditText(surface.u_isolines.max(0).to_string()), + }, + Property { + label: t!("V isolines").into_owned(), + field: PROP_SURFACE_V_ISOLINES, + value: PropValue::EditText(surface.v_isolines.max(0).to_string()), + }, + ], + }, + PropSection { + title: t!("Surface Associativity").into_owned(), + props: vec![ + Property { + label: t!("Maintain associativity").into_owned(), + field: PROP_SURFACE_MAINTAIN_ASSOCIATIVITY, + value: yes_no_choice(state.maintain_associativity), + }, + Property { + label: t!("Show associativity").into_owned(), + field: PROP_SURFACE_SHOW_ASSOCIATIVITY, + value: yes_no_choice(state.show_associativity), + }, + ], + }, + PropSection { + title: t!("Trims").into_owned(), + props: vec![ + Property { + label: t!("Trimmed surface").into_owned(), + field: PROP_SURFACE_TRIMMED, + value: PropValue::ReadOnly("No".to_string()), + }, + Property { + label: t!("Trimming edges").into_owned(), + field: PROP_SURFACE_TRIMMING_EDGES, + value: PropValue::ReadOnly("0".to_string()), + }, + ], + }, + ]; + } + let (record_history, object_show_history, show_history_mode) = + history_flags(document, handle).unwrap_or((false, false, 1)); + let (show_history, show_history_editable) = + displayed_history_state(object_show_history, show_history_mode); vec![ PropSection { title: t!("Geometry").into_owned(), @@ -1531,6 +1685,15 @@ pub fn is_history_choice(field: &str) -> bool { matches!(field, PROP_HISTORY | PROP_SHOW_HISTORY) } +pub fn is_surface_property_choice(field: &str) -> bool { + matches!( + field, + PROP_SURFACE_WIREFRAME_TYPE + | PROP_SURFACE_MAINTAIN_ASSOCIATIVITY + | PROP_SURFACE_SHOW_ASSOCIATIVITY + ) +} + pub fn is_loft_geometry_choice(field: &str) -> bool { matches!(field, PROP_LOFT_NORMALS | PROP_LOFT_CLOSED | PROP_LOFT_PERIODIC) } @@ -1538,7 +1701,11 @@ pub fn is_loft_geometry_choice(field: &str) -> bool { pub fn is_specialized_property(field: &str) -> bool { matches!(field, "solid_history_type" | PROP_BANK | PROP_SWEEP_LENGTH | PROP_LOFT_TYPE | PROP_LOFT_SECTION_COUNT - | PROP_EXTRUSION_DIRECTION_X | PROP_EXTRUSION_DIRECTION_Y | PROP_EXTRUSION_DIRECTION_Z) + | PROP_EXTRUSION_DIRECTION_X | PROP_EXTRUSION_DIRECTION_Y | PROP_EXTRUSION_DIRECTION_Z + | PROP_SURFACE_TYPE | PROP_SURFACE_WIREFRAME_TYPE + | PROP_SURFACE_U_ISOLINES | PROP_SURFACE_V_ISOLINES + | PROP_SURFACE_MAINTAIN_ASSOCIATIVITY | PROP_SURFACE_SHOW_ASSOCIATIVITY + | PROP_SURFACE_TRIMMED | PROP_SURFACE_TRIMMING_EDGES) || is_primitive_property(field) || is_history_choice(field) } @@ -3018,11 +3185,6 @@ fn extrusion_draft_grip( if !inverse.is_finite() { return None; } - let profile = value.sweep_entity.as_ref().and_then(embedded_entity)?; - let normal = crate::entities::curve::entity_curve(&profile)?.plane.normal()?; - let normal = transform - .transform_vector3(glam::DVec3::from_array(normal)) - .try_normalize()?; let center_local = inverse.transform_point3(center); let anchor = profile_grips .into_iter() @@ -3057,7 +3219,7 @@ fn extrusion_draft_grip( value.direction.y, value.direction.z, )); - let height = direction.dot(normal).abs(); + let height = direction.length(); if !direction.is_finite() || !height.is_finite() || height <= 1e-6 { return None; } @@ -3307,6 +3469,7 @@ pub fn primitive_grips( if let SolidHistoryOperation::Chamfer(value) = operation { return chamfer_distance_grips(document, handle, value); } + let is_surface = matches!(document.get_entity(handle), Some(EntityType::Surface(_))); let mut grips = Vec::new(); let mut add = |id, transform, point, shape, axis: Option<[f64; 3]>| { if let Some(world) = world_point(transform, point) { @@ -3528,27 +3691,40 @@ pub fn primitive_grips( } SolidHistoryOperation::Extrusion(value) => { let (profile_grips, profile_center) = extrusion_profile_grips(value); - grips.extend(profile_grips); + if !is_surface { + grips.extend(profile_grips); + } let direction = [value.direction.x, value.direction.y, value.direction.z]; if value.path_entity.is_none() { if let (Some(center), Some(base)) = (profile_center, matrix(value.base.transform)) { let direction = base.transform_vector3(glam::DVec3::from_array(direction)); if direction.length_squared() > 1e-12 { - grips.push(grip( + let mut height_grip = grip( GRIP_HEIGHT, center + direction, GripShape::Triangle, Some(direction.normalize()), - )); + ); + if is_surface { + // Surface construction arrows keep one stable screen + // orientation while their world-space axis continues to + // constrain the edit. + height_grip.dir = None; + } + grips.push(height_grip); } } if let Some((world, _, radial, _, _)) = extrusion_draft_grip(value) { - grips.push(grip( + let mut draft_grip = grip( GRIP_DRAFT, world, GripShape::Triangle, Some(radial), - )); + ); + if is_surface { + draft_grip.dir = None; + } + grips.push(draft_grip); } } } diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index fedae9365..1fa867915 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -22,7 +22,8 @@ fn display_tessellation( body: &Body, facet_resolution: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], + planar_isolines: bool, ) -> brep::mesh::BodyMesh { let resolution = if facet_resolution.is_finite() && facet_resolution > 0.0 { facet_resolution.clamp(0.01, 10.0) @@ -34,7 +35,8 @@ fn display_tessellation( |_| cadkernel::tessellation::display_angle_for_resolution(resolution), ); let mut tolerance = brep::mesh::TessellationTolerance::new(max_angle, TOL) - .with_isolines(isolines); + .with_uv_isolines(isolines[0], isolines[1]) + .with_planar_isolines(planar_isolines); if let Some(deflection) = chordal_deflection { tolerance = tolerance.with_chordal_deflection(deflection); } @@ -267,20 +269,42 @@ pub fn edge_wires(body: &Body) -> Vec { .collect() } -/// White wireframe used while a solid-history grip is hot. +/// White wireframe used while a solid-history grip is hot. Surface previews +/// include the requested construction isolines so their cage follows the edit. /// /// This deliberately does not touch the resident solid mesh or its entity /// wires: the selected source stays visible in blue while the candidate body /// is presented as a separate, non-pickable outline until placement. -pub fn grip_preview_wires(body: &Body, handle: acadrust::Handle) -> Vec { - tessellation(body) +pub fn grip_preview_wires( + body: &Body, + handle: acadrust::Handle, + isolines: [usize; 2], + planar_isolines: bool, +) -> Vec { + let tessellation = brep::mesh::tessellate( + body, + brep::mesh::TessellationTolerance::new( + cadkernel::tessellation::DEFAULT_ANGLE, + TOL, + ) + .with_uv_isolines(isolines[0], isolines[1]) + .with_planar_isolines(planar_isolines), + ); + tessellation .edges .into_iter() - .filter(|edge| edge.positions.len() >= 2) - .map(|edge| { + .map(|edge| edge.positions) + .chain( + tessellation + .isolines + .into_iter() + .map(|isoline| isoline.positions), + ) + .filter(|positions| positions.len() >= 2) + .map(|positions| { WireModel::solid_f64( format!("{}-GRIP-PREVIEW", handle.value()), - edge.positions, + positions, WireModel::WHITE, false, ) @@ -453,7 +477,8 @@ pub fn display_from_solid( color: [f32; 4], facet_resolution: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], + planar_isolines: bool, ) -> Option<(MeshLodSet, Vec, [f64; 3])> { use acadrust::types::Vector3; let tessellation = display_tessellation( @@ -461,6 +486,7 @@ pub fn display_from_solid( facet_resolution, chordal_deflection, isolines, + planar_isolines, ); let center = mesh_center(&tessellation.mesh)?; let wires = tessellation @@ -541,7 +567,7 @@ mod tests { use super::*; fn tri_count(body: &Body) -> usize { - display_from_solid(body, [0.7, 0.7, 0.7, 1.0], 1.0, None, 0) + display_from_solid(body, [0.7, 0.7, 0.7, 1.0], 1.0, None, [0; 2], false) .map(|(m, _, _)| m.lods[0].indices.len() / 3) .unwrap_or(0) } @@ -558,6 +584,23 @@ mod tests { assert!(tri_count(&pyramid_solid(c, 5.0, 9.0, 6).unwrap()) >= 8, "pyramid"); } + #[test] + fn planar_display_uses_independent_isoline_counts() { + let body = box_solid([0.0; 3], 10.0, 10.0, 10.0).unwrap(); + let display = |counts, planar| { + display_from_solid(&body, [0.7, 0.7, 0.7, 1.0], 1.0, None, counts, planar) + .unwrap() + .0 + .edge_verts + .len() + }; + let boundaries = display([0, 0], false); + + assert_eq!(display([1, 0], true) - boundaries, 12); + assert_eq!(display([0, 2], true) - boundaries, 24); + assert_eq!(display([2, 2], false), boundaries); + } + #[test] fn every_primitive_is_the_size_it_was_asked_for() { // Triangle counts say a mesh exists; the volume says it is the right diff --git a/src/scene/modify.rs b/src/scene/modify.rs index d8ed4fa75..6d9912c80 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -1046,9 +1046,21 @@ impl Scene { { return false; } + let (isoline_counts, planar_isolines) = match self.document.get_entity(handle) { + Some(EntityType::Surface(surface)) => ( + crate::entities::solid3d::surface_isoline_counts(surface), + crate::entities::solid3d::surface_property_state(surface).isolines, + ), + _ => ([0; 2], false), + }; self.solid_history_preview_wires.insert( handle, - crate::scene::model::solid_model::grip_preview_wires(&body, handle), + crate::scene::model::solid_model::grip_preview_wires( + &body, + handle, + isoline_counts, + planar_isolines, + ), ); true } diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index 2ce4ea895..135122a04 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -13,11 +13,18 @@ pub const GRIP_HALF_PX: f32 = 5.0; /// Screen offset for dropdown selectors anchored to a vertex. pub const GRIP_DROPDOWN_OFFSET_X_PX: f32 = 24.0; pub const GRIP_DROPDOWN_OFFSET_Y_PX: f32 = 27.0; +pub const GRIP_ADJACENT_DROPDOWN_OFFSET_X_PX: f32 = 12.0; fn marker_screen_position(mut point: Point, shape: GripShape) -> Point { - if shape == GripShape::Dropdown { - point.x += GRIP_DROPDOWN_OFFSET_X_PX; - point.y += GRIP_DROPDOWN_OFFSET_Y_PX; + match shape { + GripShape::Dropdown => { + point.x += GRIP_DROPDOWN_OFFSET_X_PX; + point.y += GRIP_DROPDOWN_OFFSET_Y_PX; + } + GripShape::DropdownAdjacent => { + point.x += GRIP_ADJACENT_DROPDOWN_OFFSET_X_PX; + } + _ => {} } point } @@ -47,6 +54,8 @@ pub struct GripEdit { pub enum GripEditMode { Stretch, Lengthen, + Radius, + ArcLength, } #[derive(Clone, Debug)] @@ -85,6 +94,18 @@ impl GripEdit { edit.mode = GripEditMode::Lengthen; edit } + + pub fn radius(handle: Handle, grip_id: usize, world: DVec3) -> Self { + let mut edit = Self::single(handle, grip_id, false, world); + edit.mode = GripEditMode::Radius; + edit + } + + pub fn arc_length(handle: Handle, grip_id: usize, world: DVec3) -> Self { + let mut edit = Self::single(handle, grip_id, false, world); + edit.mode = GripEditMode::ArcLength; + edit + } } // ── Screen-space helpers ─────────────────────────────────────────────────── diff --git a/src/scene/pipeline/circle_gpu.rs b/src/scene/pipeline/circle_gpu.rs index f38b46605..f5e898cd5 100644 --- a/src/scene/pipeline/circle_gpu.rs +++ b/src/scene/pipeline/circle_gpu.rs @@ -258,7 +258,7 @@ pub fn extract_circle_instance_from_geom_indexed( let (start_width, end_width) = if !wire.taper_widths.is_empty() { if wire.taper_widths.len() >= 2 && wire.tangent_geoms.len() == 1 { - (wire.taper_widths[0], wire.taper_widths[1]) + (wire.taper_widths[0], *wire.taper_widths.last().unwrap()) } else if wire.taper_widths.len() > geom_index { let sw = wire.taper_widths[geom_index]; let ew = wire.taper_widths.get(geom_index + 1).copied().unwrap_or(sw); diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index e3901ee3d..d71c15d58 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -116,7 +116,7 @@ pub struct WireConst { /// the quad by `world_half_width / world_per_pixel` so the band tracks zoom /// in drawing units. pub world_half_width: f32, - pub _pad1: f32, + pub is_tapered: f32, pub _pad2: f32, /// Point-marker origin as a double-single pair. `marker_normal_scale.w` /// stores the viewport-height percentage; zero disables marker scaling. @@ -592,7 +592,14 @@ pub(crate) fn emit_wire_packed( let (dists, align_end, align_total) = wire_distances(wire); let (marker_origin_high, marker_origin_low, marker_normal_scale) = marker_metadata(wire); let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) }; - let tw = |i: usize| -> f32 { wire.taper_widths.get(i).copied().unwrap_or(0.0) * 0.5 }; + let is_tapered = !wire.taper_widths.is_empty(); + let tw = |i: usize| -> f32 { + if is_tapered { + wire.taper_widths.get(i).copied().unwrap_or(0.0) * 0.5 + } else { + -1.0 + } + }; let mut instances: Vec = Vec::with_capacity(seg_count); for i in 0..seg_count { let a = wire.points[i]; @@ -636,6 +643,7 @@ pub(crate) fn emit_wire_native( ) -> (Vec, WireConst) { let (dists, align_end, align_total) = wire_distances(wire); let (marker_origin_high, marker_origin_low, marker_normal_scale) = marker_metadata(wire); + let is_tapered = !wire.taper_widths.is_empty(); let cst = WireConst { color, pat0: [wire.pattern[0], wire.pattern[1], wire.pattern[2], wire.pattern[3]], @@ -646,7 +654,7 @@ pub(crate) fn emit_wire_native( align_end, align_total, world_half_width: wire.world_width * 0.5, - _pad1: 0.0, + is_tapered: if is_tapered { 1.0 } else { 0.0 }, _pad2: 0.0, marker_origin_high, marker_origin_low, @@ -658,18 +666,12 @@ pub(crate) fn emit_wire_native( return (Vec::new(), cst); } let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) }; - // Store an endpoint/max-width ratio. The shared f32 maximum keeps drawing - // units and range out of the packed field; UNORM16 contributes only a - // relative error below 1/65535. Preserve zero as the existing constant - // width fallback sentinel. let taper_ratio = |i: usize| -> u16 { - let width = wire.taper_widths.get(i).copied().unwrap_or(0.0); - if width <= 0.0 || wire.world_width <= 0.0 { + if !is_tapered || wire.world_width <= 0.0 { 0 } else { - ((width / wire.world_width).clamp(0.0, 1.0) * u16::MAX as f32) - .round() - .max(1.0) as u16 + let width = wire.taper_widths.get(i).copied().unwrap_or(0.0); + ((width / wire.world_width).clamp(0.0, 1.0) * u16::MAX as f32).round() as u16 } }; let mut instances: Vec = Vec::with_capacity(seg_count); diff --git a/src/shaders/block_wire.wgsl b/src/shaders/block_wire.wgsl index d70301806..ba3adb899 100644 --- a/src/shaders/block_wire.wgsl +++ b/src/shaders/block_wire.wgsl @@ -24,7 +24,7 @@ struct WireConst { align_end: f32, align_total: f32, world_half_width: f32, - _pad1: f32, + is_tapered: f32, _pad2: f32, marker_origin_high: vec4, marker_origin_low: vec4, @@ -121,7 +121,8 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut let rel_a = marker_relative(in.pos_a, in.pos_a_low, in.translation, in.translation_low); let rel_b = marker_relative(in.pos_b, in.pos_b_low, in.translation, in.translation_low); - let is_world = wire_const.world_half_width > 0.0 || in.taper_ratio.x > 0.0 || in.taper_ratio.y > 0.0; + let is_tapered = wire_const.is_tapered > 0.5; + let is_world = wire_const.world_half_width > 0.0; var final_clip: vec4; var out_dist: f32; @@ -134,8 +135,8 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut // the segment and the plane normal), so the rectangle remains hosted rigidly // on the 3D plane when the camera rotates, orbits, or tilts, matching CAD // behavior and circle.wgsl planar arcs. - let world_hw_a = select(wire_const.world_half_width, in.taper_ratio.x * wire_const.world_half_width, in.taper_ratio.x > 0.0); - let world_hw_b = select(wire_const.world_half_width, in.taper_ratio.y * wire_const.world_half_width, in.taper_ratio.y > 0.0); + let world_hw_a = select(wire_const.world_half_width, in.taper_ratio.x * wire_const.world_half_width, is_tapered); + let world_hw_b = select(wire_const.world_half_width, in.taper_ratio.y * wire_const.world_half_width, is_tapered); let cur_world_hw = mix(world_hw_a, world_hw_b, which_end); let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel); diff --git a/src/shaders/block_wire_storage.wgsl b/src/shaders/block_wire_storage.wgsl index 3b3544e22..ea952bca2 100644 --- a/src/shaders/block_wire_storage.wgsl +++ b/src/shaders/block_wire_storage.wgsl @@ -43,7 +43,7 @@ struct WireConst { align_end: f32, align_total: f32, world_half_width: f32, - _pad1: f32, + is_tapered: f32, _pad2: f32, marker_origin_high: vec4, marker_origin_low: vec4, @@ -152,7 +152,8 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut let rel_a = marker_relative(seg_pos_a, seg_pos_a_low, in.translation, in.translation_low); let rel_b = marker_relative(seg_pos_b, seg_pos_b_low, in.translation, in.translation_low); - let is_world = wire_const.world_half_width > 0.0 || seg_taper_ratio.x > 0.0 || seg_taper_ratio.y > 0.0; + let is_tapered = wire_const.is_tapered > 0.5; + let is_world = wire_const.world_half_width > 0.0; var final_clip: vec4; var out_dist: f32; @@ -165,8 +166,8 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut // the segment and the plane normal), so the rectangle remains hosted rigidly // on the 3D plane when the camera rotates, orbits, or tilts, matching CAD // behavior and circle.wgsl planar arcs. - let world_hw_a = select(wire_const.world_half_width, seg_taper_ratio.x * wire_const.world_half_width, seg_taper_ratio.x > 0.0); - let world_hw_b = select(wire_const.world_half_width, seg_taper_ratio.y * wire_const.world_half_width, seg_taper_ratio.y > 0.0); + let world_hw_a = select(wire_const.world_half_width, seg_taper_ratio.x * wire_const.world_half_width, is_tapered); + let world_hw_b = select(wire_const.world_half_width, seg_taper_ratio.y * wire_const.world_half_width, is_tapered); let cur_world_hw = mix(world_hw_a, world_hw_b, which_end); let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel); diff --git a/src/shaders/circle.wgsl b/src/shaders/circle.wgsl index 22b0973f8..43d157286 100644 --- a/src/shaders/circle.wgsl +++ b/src/shaders/circle.wgsl @@ -207,32 +207,48 @@ fn in_dash(dist: f32, pat_len: f32, p0: vec4, p1: vec4) -> bool { let is_full_or_in_sweep = (sweep >= TAU - 1e-5) || (d_theta <= sweep); if in.is_world > 0.5 { - // Physical world-unit width (thick analytical circular arcs, donuts, tapered segments). - // Distance-to-edge calculations are strictly performed in the circle plane's world units - // so that 3D perspective projection yields exact concentric ellipses with invariant - // physical stroke widths across any camera tilt or acute perspective angle. - if is_full_or_in_sweep { - let t = select(0.0, clamp(d_theta / sweep, 0.0, 1.0), sweep > 1e-5); - let world_hw = mix(in.hw_ends.x, in.hw_ends.y, t); - let eff_hw = max(world_hw, 0.5 * fw); + let is_full = sweep >= TAU - 1e-5; + if is_full { + let eff_hw = max(in.hw_ends.x, 0.5 * fw); let d_world = abs(r - in.radius); let delta_px = (eff_hw - d_world) / fw; let cov = clamp(0.5 + delta_px, 0.0, 1.0); - let subpixel_fade = min(1.0, (2.0 * world_hw) / fw); + let subpixel_fade = min(1.0, (2.0 * in.hw_ends.x) / fw); alpha_cov = cov * subpixel_fade; arc_dist = in.radius * d_theta; } else { - // Outside sweep: evaluate distance to start and end endpoints for smooth round end caps - let p_start = in.radius * vec2(cos(sa), sin(sa)); - let p_end = in.radius * vec2(cos(ea), sin(ea)); - let d_start = length(in.local_pos - p_start); - let d_end = length(in.local_pos - p_end); - let eff_hw_a = max(in.hw_ends.x, 0.5 * fw); - let eff_hw_b = max(in.hw_ends.y, 0.5 * fw); - let cov_start = clamp(0.5 + (eff_hw_a - d_start) / fw, 0.0, 1.0) * min(1.0, (2.0 * in.hw_ends.x) / fw); - let cov_end = clamp(0.5 + (eff_hw_b - d_end) / fw, 0.0, 1.0) * min(1.0, (2.0 * in.hw_ends.y) / fw); - alpha_cov = max(cov_start, cov_end); - arc_dist = select(in.radius * sweep, 0.0, cov_start >= cov_end); + // Flat (radial) end caps perpendicular to arc tangent at start_angle and end_angle. + let d_start_rad = r * d_theta; + let d_end_rad = r * (sweep - d_theta); + let d_before_sa = r * mod_tau(sa - theta); + let d_after_ea = r * mod_tau(theta - ea); + + var cov_angular: f32 = 0.0; + var world_hw: f32 = 0.0; + + if is_full_or_in_sweep { + let t = select(0.0, clamp(d_theta / sweep, 0.0, 1.0), sweep > 1e-5); + world_hw = mix(in.hw_ends.x, in.hw_ends.y, t); + let d_inside = min(d_start_rad, d_end_rad); + cov_angular = clamp(0.5 + d_inside / fw, 0.0, 1.0); + arc_dist = in.radius * d_theta; + } else { + let d_outside = min(d_before_sa, d_after_ea); + if d_outside <= 0.5 * fw { + cov_angular = clamp(0.5 - d_outside / fw, 0.0, 1.0); + world_hw = select(in.hw_ends.y, in.hw_ends.x, d_before_sa <= d_after_ea); + arc_dist = select(in.radius * sweep, 0.0, d_before_sa <= d_after_ea); + } + } + + if cov_angular > 0.0 { + let eff_hw = max(world_hw, 0.5 * fw); + let d_world = abs(r - in.radius); + let delta_px = (eff_hw - d_world) / fw; + let cov_radial = clamp(0.5 + delta_px, 0.0, 1.0); + let subpixel_fade = min(1.0, (2.0 * world_hw) / fw); + alpha_cov = cov_radial * cov_angular * subpixel_fade; + } } } else { // Lineweight stroke (screen-pixel width) diff --git a/src/shaders/wire.wgsl b/src/shaders/wire.wgsl index afa7a0d75..f1622b691 100644 --- a/src/shaders/wire.wgsl +++ b/src/shaders/wire.wgsl @@ -102,7 +102,7 @@ struct VertexOut { // constant world-unit band, then the screen-pixel lineweight (LWDISPLAY off // collapses to a hairline). fn resolve_hw(taper: f32, world_hw: f32, px_hw: f32) -> f32 { - if taper > 0.0 { return max(taper / u.world_per_pixel, 0.5); } + if taper >= 0.0 { return max(taper / u.world_per_pixel, 0.5); } if world_hw > 0.0 { return max(world_hw / u.world_per_pixel, 0.5); } var display_hw = max(px_hw * u.lineweight_scale, 0.5); if u.lineweight_scale < 0.0 { @@ -156,7 +156,7 @@ fn marker_relative(position_high: vec3, position_low: vec3, instance: // UTM-scale coordinates and after a cross-drawing paste, with no jitter. let rel_a = marker_relative(in.pos_a, in.pos_a_low, in); let rel_b = marker_relative(in.pos_b, in.pos_b_low, in); - let is_world = in.misc.w > 0.0 || in.taper.x > 0.0 || in.taper.y > 0.0; + let is_world = in.misc.w > 0.0 || in.taper.x >= 0.0 || in.taper.y >= 0.0; var final_clip: vec4; var out_dist: f32; @@ -169,8 +169,8 @@ fn marker_relative(position_high: vec3, position_low: vec3, instance: // the segment and the plane normal), so the rectangle remains hosted rigidly // on the 3D plane when the camera rotates, orbits, or tilts, matching CAD // behavior and circle.wgsl planar arcs. - let world_hw_a = select(in.misc.w, in.taper.x, in.taper.x > 0.0); - let world_hw_b = select(in.misc.w, in.taper.y, in.taper.y > 0.0); + let world_hw_a = select(in.misc.w, in.taper.x, in.taper.x >= 0.0); + let world_hw_b = select(in.misc.w, in.taper.y, in.taper.y >= 0.0); let cur_world_hw = mix(world_hw_a, world_hw_b, which_end); let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel); diff --git a/src/shaders/wire_indexed.wgsl b/src/shaders/wire_indexed.wgsl index d739d6e15..a18ee51b6 100644 --- a/src/shaders/wire_indexed.wgsl +++ b/src/shaders/wire_indexed.wgsl @@ -32,7 +32,7 @@ struct WireConst { align_end: f32, align_total: f32, world_half_width: f32, - _pad1: f32, + is_tapered: f32, _pad2: f32, marker_origin_high: vec4, marker_origin_low: vec4, @@ -128,7 +128,8 @@ fn marker_relative(position_high: vec3, position_low: vec3, c: WireCon let rel_a = marker_relative(in.pos_a, in.pos_a_low, c); let rel_b = marker_relative(in.pos_b, in.pos_b_low, c); - let is_world = c.world_half_width > 0.0 || in.taper_ratio.x > 0.0 || in.taper_ratio.y > 0.0; + let is_tapered = c.is_tapered > 0.5; + let is_world = c.world_half_width > 0.0; var final_clip: vec4; var out_dist: f32; @@ -141,8 +142,8 @@ fn marker_relative(position_high: vec3, position_low: vec3, c: WireCon // the segment and the plane normal), so the rectangle remains hosted rigidly // on the 3D plane when the camera rotates, orbits, or tilts, matching CAD // behavior and circle.wgsl planar arcs. - let world_hw_a = select(c.world_half_width, in.taper_ratio.x * c.world_half_width, in.taper_ratio.x > 0.0); - let world_hw_b = select(c.world_half_width, in.taper_ratio.y * c.world_half_width, in.taper_ratio.y > 0.0); + let world_hw_a = select(c.world_half_width, in.taper_ratio.x * c.world_half_width, is_tapered); + let world_hw_b = select(c.world_half_width, in.taper_ratio.y * c.world_half_width, is_tapered); let cur_world_hw = mix(world_hw_a, world_hw_b, which_end); let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel); diff --git a/src/snap.rs b/src/snap.rs index b3021cc22..8969eff91 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1023,12 +1023,25 @@ impl Snapper { f32::MAX } }; + let flat_ortho = view_rot.z_axis.x.abs() < 1e-9 + && view_rot.z_axis.y.abs() < 1e-9 + && (view_rot.w_axis.w - 1.0).abs() < 1e-6; // Returns false when the wire's AABB does not overlap the snap circle — // safe to skip all vertex work for this wire. // UNBOUNDED_AABB (±infinity) passes through automatically without a // special-case branch because the arithmetic is exact for infinities. let wire_in_range = |wire: &WireModel| -> bool { + // In a tilted or perspective view `cursor_world` lies on the active + // construction plane, while the visible vertex may be anywhere on + // the same view ray. A world-XY AABB comparison can therefore reject + // a 3-D solid corner that is directly under the cursor. The scene's + // screen-space interaction index already performs the broad phase + // for those views; small unindexed drawings are cheap enough to let + // the exact screen-aperture checks below decide. + if !flat_ortho { + return true; + } // The AABB is stored in f32, so at UTM-scale coordinates each bound // is quantized by up to ~1 ulp (≈ coord × 2⁻²³ ≈ 0.7 m at 5.7e6). // When zoomed in hard the snap radius shrinks below that, so the diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index bafeede77..3f2432bad 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -790,7 +790,7 @@ fn draw_grip_marker( b.close(); }), GripShape::Circle => canvas::Path::circle(Point::new(sp.x, sp.y), h), - GripShape::Dropdown => canvas::Path::new(|b| { + GripShape::Dropdown | GripShape::DropdownAdjacent => canvas::Path::new(|b| { b.move_to(Point::new(sp.x - h, sp.y - h * 0.5)); b.line_to(Point::new(sp.x + h, sp.y - h * 0.5)); b.line_to(Point::new(sp.x, sp.y + h)); @@ -840,7 +840,10 @@ fn draw_grip_marker( } else { palette.primary.base.color }; - let fill = if grip.shape == GripShape::Dropdown { + let fill = if matches!( + grip.shape, + GripShape::Dropdown | GripShape::DropdownAdjacent + ) { color } else { palette.background.base.color.scale_alpha(0.7) @@ -3844,4 +3847,3 @@ mod selection_visual_color_tests { assert!((selection_fill_alpha(100.0, true) - 0.45).abs() < 1e-5); } } - diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs new file mode 100644 index 000000000..880b87f7d --- /dev/null +++ b/src/ui/ribbon/draw_panel.rs @@ -0,0 +1,261 @@ +//! Additional drawing tools, reached from the Draw panel's title. + +use std::time::Duration; + +use iced::widget::{button, column, container, row, scrollable, text, tooltip}; +use iced::{Element, Fill, Theme}; + +use super::widgets::{ + make_icon, make_tip, muted_text_style, popup_panel_style, popup_row_style, tip_style, + tool_btn_style, +}; +use super::{dropdown_backdrop, position_ribbon_dropdown, Ribbon}; +use crate::app::Message; +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"; +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)], +} + +const TOOLS: &[Tool] = &[ + Tool { + command: "SPLINE", + label: "Spline Fit", + icon: include_bytes!("../../../assets/icons/spline.svg"), + options: &[], + }, + Tool { + command: "SPLINECV", + label: "Spline CV", + icon: include_bytes!("../../../assets/icons/spline_cv.svg"), + options: &[], + }, + Tool { + command: "XLINE", + label: "Construction Line", + icon: include_bytes!("../../../assets/icons/xline.svg"), + options: &[], + }, + Tool { + command: "RAY", + label: "Ray", + 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", + icon: include_bytes!("../../../assets/icons/divide.svg"), + options: &[], + }, + Tool { + command: "MEASURE", + label: "Measure", + icon: include_bytes!("../../../assets/icons/measure.svg"), + options: &[], + }, + Tool { + command: "REGION", + label: "Region", + icon: include_bytes!("../../../assets/icons/region.svg"), + options: &[], + }, + Tool { + command: "WIPEOUT", + label: "Wipeout", + icon: include_bytes!("../../../assets/icons/wipeout.svg"), + options: &[], + }, + Tool { + command: "BOUNDARY", + label: "Boundary", + icon: include_bytes!("../../../assets/icons/boundary.svg"), + options: &[], + }, + Tool { + command: "HELIX", + label: "Helix", + icon: include_bytes!("../../../assets/icons/helix.svg"), + options: &[], + }, + Tool { + command: "DONUT", + label: "Donut", + icon: include_bytes!("../../../assets/icons/donut.svg"), + options: &[], + }, + Tool { + command: "REVCLOUD", + label: "Revision Cloud", + icon: include_bytes!("../../../assets/icons/revcloud.svg"), + options: &[ + ("REVCLOUD_RECTANGULAR", "Rectangular"), + ("REVCLOUD_POLYGONAL", "Polygonal"), + ("REVCLOUD_FREEHAND", "Freehand"), + ], + }, +]; + +pub(super) fn owns_dropdown(id: &str) -> bool { + id == PANEL_ID + || TOOLS + .iter() + .any(|tool| tool.command == id && !tool.options.is_empty()) +} + +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 + } +} + +pub(super) fn group_title<'a>(title: &'static str, open: &Option, font_size: f32) -> Element<'a, Message> { + if title != "Draw" || TOOLS.is_empty() { + return container(text(t!(title)).size(font_size).style(muted_text_style)) + .padding([1, 4]) + .into(); + } + let expanded = open.as_deref().is_some_and(owns_dropdown); + let arrow = if expanded { + icons::themed_arrow_up(7.0) + } else { + icons::themed_arrow_down(7.0) + }; + PosReport::new( + PANEL_ID, + button( + row![text(t!(title)).size(font_size), arrow] + .spacing(4) + .align_y(iced::Center), + ) + .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> { + let face = button(make_icon(IconKind::Svg(tool.icon), 23.0 * SCALE)) + .on_press(Message::DropdownSelectItem { + dropdown_id: PANEL_ID, + cmd: tool.command, + }) + .style(move |theme: &Theme, status| tool_btn_style(theme, active, status)) + .width(if tool.options.is_empty() { + CELL + } else { + CELL - 11.0 * SCALE + }) + .height(CELL) + .padding(3.0 * SCALE); + let tip = format!("{}\n{} {}", t!(tool.label), t!("Command:"), tool.command); + let face: Element<'static, Message> = tooltip(face, make_tip(tip), tooltip::Position::Bottom) + .delay(Duration::from_millis(400)) + .style(tip_style) + .into(); + if !tool.options.is_empty() { + row![ + face, + button(icons::themed_arrow_down(6.0 * SCALE)) + .on_press(Message::ToggleRibbonDropdown(tool.command.to_string())) + .style(move |theme: &Theme, status| tool_btn_style(theme, false, status)) + .width(11.0 * SCALE) + .height(CELL) + .padding(2.0 * SCALE) + ] + .into() + } else { + face + } +} + +pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element<'a, Message> { + 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 left = anchor + .map_or(x, |b| b.x) + .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 + .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, + |tool| tool.options.len() as f32 * OPTION_HEIGHT, + ); + let contents: Element<'static, Message> = if let Some(tool) = option_tool { + column( + tool.options + .iter() + .map(|&(cmd, label)| { + button(text(t!(label)).size(11)) + .on_press(Message::DropdownSelectItem { + dropdown_id: tool.command, + cmd, + }) + .style(popup_row_style) + .width(Fill) + .height(OPTION_HEIGHT) + .padding([3, 7]) + .into() + }) + .collect::>>(), + ) + .into() + } else { + column( + TOOLS + .chunks(cols) + .map(|tools| { + row(tools + .iter() + .map(|tool| { + tool_button(tool, ribbon.active_tool.as_deref() == Some(tool.command)) + }) + .collect::>()) + .spacing(GAP) + .into() + }) + .collect::>>(), + ) + .spacing(GAP) + .padding(6) + .into() + }; + let panel = container(scrollable(contents).height(content_height.min(available_height))) + .width(width) + .style(|theme: &Theme| { + let mut style = popup_panel_style(theme); + style.border.color = theme.palette().primary.base.color; + style + }); + dropdown_backdrop(position_ribbon_dropdown(panel.into(), false, left, top)) +} diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 05d3efac4..d15e8f792 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -21,6 +21,7 @@ use crate::plugin::all_ribbon_modules; use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem}; mod widgets; +mod draw_panel; use widgets::{StyleContext, *}; mod collapse; use collapse::{CollapsePanels, Panel}; @@ -369,6 +370,9 @@ impl Ribbon { self.open_dropdown = None; } else { self.open_dropdown = Some(id.to_string()); + if draw_panel::owns_dropdown(id) { + self.collapsed_open = None; + } } } pub fn close_dropdown(&mut self) { @@ -421,7 +425,7 @@ impl Ribbon { pub fn select_dropdown_item(&mut self, dropdown_id: &'static str, cmd: &'static str) { self.last_cmd.insert(dropdown_id, cmd); - self.open_dropdown = None; + self.close_dropdown(); } // ── View ────────────────────────────────────────────────────────────── @@ -761,6 +765,10 @@ impl Ribbon { } let open_id = self.open_dropdown.as_deref()?; + if draw_panel::owns_dropdown(open_id) { + return Some(draw_panel::overlay(self, open_id, win)); + } + if open_id == UNDO_HISTORY_ID || open_id == REDO_HISTORY_ID { let is_undo = open_id == UNDO_HISTORY_ID; let labels = if is_undo { undo_labels } else { redo_labels }; @@ -1401,20 +1409,15 @@ fn render_group<'a>( r.push(e) }); - column![ + draw_panel::group_anchor(group.title, column![ tools_el, - container( - text(t!(group.title)) - .size(group_title_font_size as f32) - .style(muted_text_style), - ) - .padding([1, 4]), + draw_panel::group_title(group.title, open_dd, group_title_font_size as f32), ] .align_x(iced::Center) .spacing(0) .padding([3u16, 4]) .height(Length::Fixed(widgets::tool_bar_h(group_title_font_size as f32))) - .into() + .into()) } /// The top-level command id of a ribbon item, if it has one. diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index a5ffa6a22..cdebc8d2a 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -7,13 +7,106 @@ use iced::widget::{ use iced::{Background, Border, Element, Fill, Theme}; use std::fmt; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +/// Width of the vertical tab rail. Wide enough for the longest translated +/// page name; German and Russian are the ones that set it. +const TAB_RAIL_WIDTH: f32 = 178.0; +/// The dialog's own size when the modal is laid out intrinsically. +const DIALOG_WIDTH: f32 = 880.0; +const DIALOG_HEIGHT: f32 = 620.0; + +/// Which page of the Options dialog is showing. +/// +/// Persisted: with nine pages, reopening on General every time means hunting +/// for the one you were last in. +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "kebab-case")] pub enum OptionsTab { #[default] General, + Files, + OpenAndSave, Display, + Drafting, + Modeling, Selection, - Drawing, + UserPreferences, +} + +/// Application preferences the dialog reads that are plain scalars on the app. +/// +/// Gathered rather than passed one by one: `view_window` already carries a +/// long positional list, and several of these are adjacent `bool`s that would +/// swap silently at the call site. +#[derive(Clone, Copy)] +pub struct AppPrefs { + /// SAVETIME, minutes between recovery saves; 0 disables. + pub savetime_min: i32, + /// ISAVEBAK: keep a `.bak` when overwriting. + pub backup_on_save: bool, + /// TEXTFILL: fill TrueType glyphs rather than drawing them hollow. + pub textfill: bool, + /// CLIPROMPTLINES: prompt lines shown above the command window. + pub cliprompt_lines: i32, + /// COMMANDLINEFADETIME: how long overlay history lines stay visible, in ms. + pub commandline_fade_ms: i32, + /// ZOOMWHEEL: reverse the mouse-wheel zoom direction. + pub zoom_wheel_reversed: bool, + /// ZOOMFACTOR, 3..=100. + pub zoom_factor: i32, + /// TEXTEDITMODE: TEXTEDIT keeps prompting for the next object. + pub texteditmode: bool, + /// DIMCONTINUEMODE: continued dimensions inherit the base dimension's style. + pub dimension_continue_mode: i16, + /// QDIM extension-origin priority: 0 endpoints, 1 intersections. + pub qdim_snap_priority: u8, + /// ANNOAUTOSCALE, -4..=4. The sign is on/off; the magnitude selects which + /// objects a newly added scale reaches. + pub annotation_auto_scale: i8, + /// Polar tracking increment in degrees. + pub polar_increment_deg: f32, + /// NAVVCUBE: show the navigation cube. + pub show_viewcube: bool, + /// UCSICON: show the UCS icon. + pub show_ucs_icon: bool, + /// UCSICON ORigin: draw it at the origin rather than the corner. + pub ucs_icon_at_origin: bool, +} + +/// The fixed locations the Files page lists. +/// +/// `None` where the platform could not give one — the row then says so and +/// its button is disabled rather than opening nothing. +#[derive(Clone, Default)] +pub struct Folders { + pub config: Option, + pub plot_styles: Option, + pub plugins: Option, + pub autosave: Option, +} + +/// Values read from the current drawing's header rather than from preferences. +/// +/// These are saved in the DWG, so they follow the drawing rather than the +/// application — the page labels them as such. `available` is false when no +/// drawing is open and the controls are not drawn at all. +#[derive(Clone, Copy)] +pub struct DrawingPrefs { + pub available: bool, + /// ISOLINES. + pub isolines: i16, + /// DISPSILH. + pub display_silhouette: bool, + /// SURFU / SURFV. + pub surface_u: i16, + pub surface_v: i16, + /// SURFTYPE: 5, 6 or 8. + pub surface_type: i16, + /// SOLIDHIST. + pub record_solid_history: bool, + /// SHOWHIST, 0..=2. + pub show_solid_history: i16, } /// The Selection-card settings that live on `UserSettings` rather than on the @@ -32,6 +125,8 @@ pub struct SelectionPrefs { pub pick_drag_rect: bool, /// GRIPOBJLIMIT, 0..=32767; 0 = no limit. pub grip_object_limit: i32, + /// Selection cycling: a click where objects overlap opens a picker. + pub selection_cycling: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -58,6 +153,10 @@ pub fn view_window<'a>( active_tab: OptionsTab, cursor_size: i32, selection: SelectionPrefs, + prefs: AppPrefs, + snap_angle_input: &'a str, + drawing_prefs: DrawingPrefs, + folders: Folders, double_click_block_refedit: bool, double_click_block_attedit: bool, cursor_type: CursorType, @@ -178,8 +277,50 @@ pub fn view_window<'a>( ] .spacing(12) .align_y(iced::Center), - Space::new().height(22), - text(crate::tr!("options", "open-save-section")).size(15), + Space::new().height(24), + text(crate::t!("Applications")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Installed plugins and their sources")).size(12).width(Fill), + button(text(crate::t!("Plugins…")).size(11)) + .on_press(Message::PluginManagerOpen) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(10), + row![ + text(crate::t!("Keyboard shortcuts")).size(12).width(Fill), + button(text(crate::t!("Keyboard Shortcuts…")).size(11)) + .on_press(Message::ShortcutsPanelOpen) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(10), + row![ + text(crate::t!("Command aliases")).size(12).width(Fill), + button(text(crate::t!("Command Aliases…")).size(11)) + .on_press(Message::AliasEditorOpen) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(10) + .align_y(iced::Center), + ] + .spacing(0) + .width(sizing.width); + + + // Saving preferences, gathered onto one page. The format and the file + // association were on General, which had become a page of three unrelated + // controls; the autosave interval and the backup toggle have existed since + // #205 with no control at all. + let open_and_save = column![ + text(crate::t!("Open and Save")).size(15), + Space::new().height(10), Space::new().height(10), row![ text(crate::tr!("options", "default-save-format-label")).size(12).width(150), @@ -248,6 +389,53 @@ pub fn view_window<'a>( )) .size(11) .width(sizing.width), + Space::new().height(22), + text(crate::t!("File Safety")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Automatic save")).size(12).width(150), + slider(0..=120, prefs.savetime_min.clamp(0, 120), Message::SaveTimeChanged) + .step(1) + .width(Fill), + text(if prefs.savetime_min <= 0 { + crate::t!("Off").into_owned() + } else { + format!("{} min", prefs.savetime_min) + }) + .size(11) + .width(52), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!( + "Minutes between recovery saves to a .sv$ file; 0 turns it off (SAVETIME)." + )) + .size(11) + .width(sizing.width), + Space::new().height(14), + row![ + iced::widget::checkbox(prefs.backup_on_save) + .on_toggle(Message::BackupOnSaveChanged) + .size(15), + text(crate::t!("Keep a .bak copy when overwriting a drawing (ISAVEBAK)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(22), + text(crate::t!("Plotting")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Plot device, paper, scale and plot styles")) + .size(12) + .width(Fill), + button(text(crate::t!("Plot and Page Setup…")).size(11)) + .on_press(Message::PlotDialogOpen) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(10) + .align_y(iced::Center), ] .spacing(0) .width(sizing.width); @@ -615,6 +803,75 @@ pub fn view_window<'a>( .size(11) .width(sizing.width), ) + .push(Space::new().height(14)) + .push( + row![ + iced::widget::checkbox(prefs.textfill) + .on_toggle(Message::TextFillChanged) + .size(15), + text(crate::t!("Fill TrueType glyphs (TEXTFILL)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + ) + .push(Space::new().height(24)) + .push(text(crate::t!("Command Line")).size(15)) + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Prompt lines")).size(12).width(140), + slider( + 0..=50, + prefs.cliprompt_lines.clamp(0, 50), + Message::ClipromptLinesChanged, + ) + .step(1) + .width(Fill), + text(prefs.cliprompt_lines.clamp(0, 50).to_string()) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(6)) + .push( + text(crate::t!( + "Temporary prompt lines shown above the command window (CLIPROMPTLINES)." + )) + .size(11) + .width(sizing.width), + ) + .push(Space::new().height(12)) + .push( + row![ + text(crate::t!("History fade time")).size(12).width(140), + slider( + 0..=60000, + prefs.commandline_fade_ms.clamp(0, 60000), + Message::CommandLineFadeChanged, + ) + .step(250) + .width(Fill), + text(if prefs.commandline_fade_ms <= 0 { + crate::t!("Off").into_owned() + } else { + format!("{:.1} s", prefs.commandline_fade_ms as f32 / 1000.0) + }) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(6)) + .push( + text(crate::t!( + "How long overlay history lines stay visible; 0 skips them (COMMANDLINEFADETIME)." + )) + .size(11) + .width(sizing.width), + ) .push(Space::new().height(24)) .push(text(crate::t!("Ribbon")).size(15)) .push(Space::new().height(10)) @@ -712,6 +969,15 @@ pub fn view_window<'a>( ] .spacing(8) .align_y(iced::Center), + Space::new().height(10), + row![ + iced::widget::checkbox(selection.selection_cycling) + .on_toggle(Message::SelectionCyclingChanged) + .size(15), + text(crate::t!("Clicking overlapping objects opens a picker")).size(12), + ] + .spacing(8) + .align_y(iced::Center), Space::new().height(24), row![ text(crate::t!("Visual Effect Settings")).size(15), @@ -918,7 +1184,129 @@ pub fn view_window<'a>( .spacing(0) .width(sizing.width); - let drawing = column![ + // ANNOAUTOSCALE's magnitude decides which objects a newly added scale + // reaches — 1 skips layers that are off, frozen, locked or frozen in the + // viewport, 2 keeps locked ones, 3 skips only locked, 4 takes everything. + // The sign is the on/off the status-bar pill flips, so the mode survives + // being switched off and back on. + let auto_scale_options = [ + (0i8, "Off"), + (1, "Skip objects on layers that are off, frozen or locked"), + (2, "Skip objects on layers that are off or frozen"), + (3, "Skip objects on locked layers"), + (4, "All annotative objects"), + ] + .into_iter() + .map(|(value, label)| Labelled { + value, + label: crate::t!(label).into_owned(), + }) + .collect::>(); + let selected_auto_scale = auto_scale_options + .iter() + .find(|choice| choice.value == prefs.annotation_auto_scale.max(0)) + .cloned(); + + let qdim_options = [ + (0u8, "Endpoints"), + (1, "Intersections"), + ] + .into_iter() + .map(|(value, label)| Labelled { + value, + label: crate::t!(label).into_owned(), + }) + .collect::>(); + let selected_qdim = qdim_options + .iter() + .find(|choice| choice.value == prefs.qdim_snap_priority) + .cloned(); + + let user_prefs = column![ + text(crate::t!("User Preferences")).size(15), + Space::new().height(10), + text(crate::t!("Zoom")).size(15), + Space::new().height(10), + row![ + iced::widget::checkbox(prefs.zoom_wheel_reversed) + .on_toggle(Message::ZoomWheelReversedChanged) + .size(15), + text(crate::t!("Reverse mouse wheel zoom (ZOOMWHEEL)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(12), + row![ + text(crate::t!("Zoom factor")).size(12).width(150), + slider(3..=100, prefs.zoom_factor.clamp(3, 100), Message::ZoomFactorChanged) + .step(1) + .width(Fill), + text(prefs.zoom_factor.clamp(3, 100).to_string()).size(11).width(44), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!("How far one wheel notch zooms (ZOOMFACTOR).")) + .size(11) + .width(sizing.width), + Space::new().height(24), + text(crate::t!("Text and Dimensions")).size(15), + Space::new().height(10), + row![ + iced::widget::checkbox(prefs.texteditmode) + .on_toggle(Message::TextEditModeChanged) + .size(15), + text(crate::t!("TEXTEDIT edits one object and ends (TEXTEDITMODE)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(12), + row![ + iced::widget::checkbox(prefs.dimension_continue_mode == 1) + .on_toggle(Message::DimContinueModeChanged) + .size(15), + text(crate::t!( + "Continued dimensions inherit the base dimension's layer and style (DIMCONTINUEMODE)" + )) + .size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(12), + row![ + text(crate::t!("QDIM origin priority")).size(12).width(150), + iced::widget::pick_list(selected_qdim, qdim_options, |choice| choice.label.clone()) + .on_select(|choice| Message::QdimSnapPriorityChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!( + "Which points QDIM measures from. Also settable inside the command." + )) + .size(11) + .width(sizing.width), + Space::new().height(24), + text(crate::t!("Annotation")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Add scales automatically")).size(12).width(150), + iced::widget::pick_list(selected_auto_scale, auto_scale_options, |choice| { + choice.label.clone() + }) + .on_select(|choice| Message::AnnoAutoScaleChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!( + "Which annotative objects pick up a newly set annotation scale (ANNOAUTOSCALE)." + )) + .size(11) + .width(sizing.width), + Space::new().height(24), text(crate::t!("Block Edit")).size(15), Space::new().height(10), row![ @@ -938,6 +1326,336 @@ pub fn view_window<'a>( ] .spacing(8) .align_y(iced::Center), + Space::new().height(24), + text(crate::t!("Drawing Units")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Length, angle and insertion units")) + .size(12) + .width(Fill), + button(text(crate::t!("Drawing Units…")).size(11)) + .on_press(Message::OpenDrawingUnits) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(10) + .align_y(iced::Center), + ] + .spacing(0) + .width(sizing.width); + + + // Snap modes, grid and object snap stay in the Drafting Settings dialog. + // These two have no home: Drafting Settings shows the rotation angle but + // offers only Reset, and the polar increment lives solely in a status-bar + // pop-up. + let polar_options = [90.0f32, 45.0, 30.0, 22.5, 18.0, 15.0, 10.0, 5.0, 1.0] + .into_iter() + .map(|value| Labelled { + label: format!("{}°", crate::app::settings::format_snap_angle(value)), + value, + }) + .collect::>(); + let selected_polar = polar_options + .iter() + .find(|choice| (choice.value - prefs.polar_increment_deg).abs() < 1e-4) + .cloned() + .unwrap_or_else(|| Labelled { + value: prefs.polar_increment_deg, + label: format!( + "{}°", + crate::app::settings::format_snap_angle(prefs.polar_increment_deg) + ), + }); + + let drafting = column![ + text(crate::t!("Drafting")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Drafting rotation")).size(12).width(150), + text_input("0", snap_angle_input) + .on_input(Message::SnapAngleInputChanged) + .width(110), + text(crate::t!("degrees")).size(11), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!( + "Rotates the crosshair and the snap grid in the active UCS (SNAPANG)." + )) + .size(11) + .width(sizing.width), + Space::new().height(14), + row![ + text(crate::t!("Polar tracking increment")).size(12).width(150), + iced::widget::pick_list(Some(selected_polar), polar_options, |choice| { + choice.label.clone() + }) + .on_select(|choice| Message::PolarIncrementChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(24), + text(crate::t!("Snap and Grid")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Snap modes, grid spacing and object snap")) + .size(12) + .width(Fill), + button(text(crate::t!("Drafting Settings…")).size(11)) + .on_press(Message::ToggleSnapPopup) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(10) + .align_y(iced::Center), + ] + .spacing(0) + .width(sizing.width); + + + let surface_type_options = [ + (5i16, "Quadratic B-spline"), + (6, "Cubic B-spline"), + (8, "Bezier"), + ] + .into_iter() + .map(|(value, label)| Labelled { + value, + label: format!("{} ({value})", crate::t!(label)), + }) + .collect::>(); + let selected_surface_type = surface_type_options + .iter() + .find(|choice| choice.value == drawing_prefs.surface_type) + .cloned(); + + let show_history_options = [ + (0i16, "Never"), + (1, "As set per solid"), + (2, "Always"), + ] + .into_iter() + .map(|(value, label)| Labelled { + value, + label: crate::t!(label).into_owned(), + }) + .collect::>(); + let selected_show_history = show_history_options + .iter() + .find(|choice| choice.value == drawing_prefs.show_solid_history.clamp(0, 2)) + .cloned(); + + let mut modeling = column![ + text(crate::t!("3D Modeling")).size(15), + Space::new().height(10), + text(crate::t!("Display Tools")).size(15), + Space::new().height(10), + row![ + iced::widget::checkbox(prefs.show_viewcube) + .on_toggle(Message::ShowViewCubeChanged) + .size(15), + text(crate::t!("Show the navigation cube (NAVVCUBE)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(10), + row![ + iced::widget::checkbox(prefs.show_ucs_icon) + .on_toggle(Message::ShowUcsIconChanged) + .size(15), + text(crate::t!("Show the UCS icon (UCSICON)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(10), + row![ + iced::widget::checkbox(prefs.ucs_icon_at_origin) + .on_toggle(Message::UcsIconAtOriginChanged) + .size(15), + text(crate::t!("Place the UCS icon at the origin (UCSICON ORigin)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + ]; + + if drawing_prefs.available { + modeling = modeling + .push(Space::new().height(24)) + .push( + row![ + text(crate::t!("Display Resolution")).size(15), + Space::new().width(10), + text(crate::t!("applies to the current drawing")).size(11), + ] + .align_y(iced::Center), + ) + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Isolines per surface")).size(12).width(150), + // Retessellate once when dragging ends. + slider( + 0..=64, + drawing_prefs.isolines.clamp(0, 64), + Message::IsolinesChanged, + ) + .step(1i16) + .on_release(Message::IsolinesReleased) + .width(Fill), + text(drawing_prefs.isolines.clamp(0, 64).to_string()) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(10)) + .push( + row![ + iced::widget::checkbox(drawing_prefs.display_silhouette) + .on_toggle(Message::DispSilhChanged) + .size(15), + text(crate::t!("Show silhouette edges on solids (DISPSILH)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + ) + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Surface density U")).size(12).width(150), + slider( + 0..=200, + drawing_prefs.surface_u.clamp(0, 200), + Message::SurfaceUChanged, + ) + .step(1i16) + .width(Fill), + text(drawing_prefs.surface_u.clamp(0, 200).to_string()) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Surface density V")).size(12).width(150), + slider( + 0..=200, + drawing_prefs.surface_v.clamp(0, 200), + Message::SurfaceVChanged, + ) + .step(1i16) + .width(Fill), + text(drawing_prefs.surface_v.clamp(0, 200).to_string()) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Surface type")).size(12).width(150), + iced::widget::pick_list( + selected_surface_type, + surface_type_options, + |choice| choice.label.clone(), + ) + .on_select(|choice| Message::SurfaceTypeChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(24)) + .push( + row![ + text(crate::t!("Solid History")).size(15), + Space::new().width(10), + text(crate::t!("applies to the current drawing")).size(11), + ] + .align_y(iced::Center), + ) + .push(Space::new().height(10)) + .push( + row![ + iced::widget::checkbox(drawing_prefs.record_solid_history) + .on_toggle(Message::SolidHistChanged) + .size(15), + text(crate::t!("Record the history of composite solids (SOLIDHIST)")) + .size(12), + ] + .spacing(8) + .align_y(iced::Center), + ) + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Show solid history")).size(12).width(150), + iced::widget::pick_list( + selected_show_history, + show_history_options, + |choice| choice.label.clone(), + ) + .on_select(|choice| Message::ShowHistChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + ); + } + + let modeling = modeling.spacing(0).width(sizing.width); + + + // The application has no support-file search path, so this page does not + // pretend to offer one. It shows where things actually live and opens the + // folder — which is the question people are really asking when they go + // looking for a Files page. + let folder_row = |label: std::borrow::Cow<'a, str>, path: Option| { + let shown = path.clone().unwrap_or_else(|| crate::t!("Not available").into_owned()); + let mut open = button(text(crate::t!("Open folder")).size(11)) + .padding([4, 10]) + .style(button::secondary); + if let Some(path) = path { + open = open.on_press(Message::OpenFolder(path)); + } + row![ + column![ + text(label).size(12), + text(shown).size(11), + ] + .spacing(2) + .width(Fill), + open, + ] + .spacing(10) + .align_y(iced::Center) + }; + + let files = column![ + text(crate::t!("Files")).size(15), + Space::new().height(6), + text(crate::t!( + "Where the application keeps its own files. These locations are fixed." + )) + .size(11) + .width(sizing.width), + Space::new().height(16), + folder_row(crate::t!("Configuration"), folders.config.clone()), + Space::new().height(12), + folder_row(crate::t!("Plot styles"), folders.plot_styles.clone()), + Space::new().height(12), + folder_row(crate::t!("Plugins"), folders.plugins.clone()), + Space::new().height(12), + folder_row(crate::t!("Autosave files"), folders.autosave.clone()), ] .spacing(0) .width(sizing.width); @@ -946,41 +1664,56 @@ pub fn view_window<'a>( OptionsTab::General => general.into(), OptionsTab::Display => display_element.into(), OptionsTab::Selection => selection.into(), - OptionsTab::Drawing => drawing.into(), + OptionsTab::Files => files.into(), + OptionsTab::OpenAndSave => open_and_save.into(), + OptionsTab::Drafting => drafting.into(), + OptionsTab::Modeling => modeling.into(), + OptionsTab::UserPreferences => user_prefs.into(), }; + // A vertical rail rather than a horizontal strip: the tab names are + // translated into 21 languages, and a row of them stops fitting long + // before the list of pages is complete. let tab_button = |label, tab| { let selected = active_tab == tab; - button(text(label).size(12)) + button(text(label).size(12.5)) .on_press(Message::OptionsTabChanged(tab)) - .padding([6, 14]) - .style(if selected { button::primary } else { button::secondary }) + .padding([7, 12]) + .width(Fill) + .style(if selected { button::primary } else { button::text }) }; - let tabs = row![ + let tabs = column![ tab_button(crate::t!("General"), OptionsTab::General), + tab_button(crate::t!("Files"), OptionsTab::Files), + tab_button(crate::t!("Open and Save"), OptionsTab::OpenAndSave), tab_button(crate::t!("Display"), OptionsTab::Display), + tab_button(crate::t!("Drafting"), OptionsTab::Drafting), + tab_button(crate::t!("3D Modeling"), OptionsTab::Modeling), tab_button(crate::t!("Selection"), OptionsTab::Selection), - tab_button(crate::t!("Drawing"), OptionsTab::Drawing), + tab_button(crate::t!("User Preferences"), OptionsTab::UserPreferences), ] - .spacing(6); + .spacing(2) + .width(iced::Length::Fixed(TAB_RAIL_WIDTH)); - let body = column![ - tabs, - Space::new().height(12), + let pane = column![ // Keep the scrollbar in its own lane instead of floating over the // controls at the trailing edge of the Options content. - scrollable(content).spacing(8).height(sizing.height), + scrollable(content).spacing(8).height(Fill), Space::new().height(12), - row![Space::new().width(sizing.width), close], + row![Space::new().width(Fill), close], ] - .width(sizing.width) + .width(Fill) .height(sizing.height); + let body = row![tabs, Space::new().width(18), pane] + .width(sizing.width) + .height(sizing.height); + let intrinsic = sizing.width == crate::ui::modal::ModalSizing::INTRINSIC.width; container(body) .style(container::rounded_box) .padding([16, 18]) - .width(if intrinsic { iced::Length::Fixed(540.0) } else { sizing.width }) - .height(if intrinsic { iced::Length::Fixed(560.0) } else { sizing.height }) + .width(if intrinsic { iced::Length::Fixed(DIALOG_WIDTH) } else { sizing.width }) + .height(if intrinsic { iced::Length::Fixed(DIALOG_HEIGHT) } else { sizing.height }) .into() }