From f20bf93dfe3dce5d7c6cd8248341c89fba02e304 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:31:27 +0200 Subject: [PATCH 01/20] feat(plugin): emit SelectionChangedV4 to V4 plugins and add ocs_plugin_api architecture docs - Add HostNotification::SelectionChangedV4 { tab_id, handles } with discriminant 7 - Broadcast active-tab selection changes at update() and automation_op boundaries - Use stable order-independent signature to avoid spurious hover notifications - Clear Scene selection in automation new/open to avoid stale handles - Add crates/ocs_plugin_api/ARCHITECTURE.md and module-level rustdoc - Cross-link README.md and docs/plugin-architecture.md - Fix test env race with shared ENV_LOCK and EnvVarGuard helpers --- crates/ocs_plugin_api/ARCHITECTURE.md | 261 +++++++++++++++++++ crates/ocs_plugin_api/README.md | 10 +- crates/ocs_plugin_api/src/host.rs | 12 + crates/ocs_plugin_api/src/host_v4.rs | 6 + crates/ocs_plugin_api/src/ipc/mod.rs | 15 ++ crates/ocs_plugin_api/src/ipc/protocol.rs | 10 + crates/ocs_plugin_api/src/ipc/transport.rs | 8 + crates/ocs_plugin_api/src/ipc/v4/client.rs | 4 +- crates/ocs_plugin_api/src/ipc/v4/mod.rs | 9 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 19 ++ crates/ocs_plugin_api/src/lib.rs | 21 ++ crates/ocs_plugin_api/src/manifest.rs | 18 +- crates/ocs_plugin_api/src/process.rs | 21 +- crates/ocs_plugin_api/src/process/manager.rs | 15 ++ crates/ocs_plugin_api/src/process/v4.rs | 23 +- crates/ocs_plugin_api/src/runner.rs | 12 + crates/ocs_plugin_api/src/shm.rs | 15 ++ crates/ocs_plugin_api/src/type_registry.rs | 10 +- crates/ocs_plugin_api/src/version_info.rs | 12 + docs/plugin-architecture.md | 4 +- src/app/automation.rs | 13 + src/app/mod.rs | 6 + src/app/update/mod.rs | 30 +++ src/plugin/v4_support.rs | 9 + src/scene/selection.rs | 46 ++++ 25 files changed, 592 insertions(+), 17 deletions(-) create mode 100644 crates/ocs_plugin_api/ARCHITECTURE.md diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md new file mode 100644 index 00000000..19388eb7 --- /dev/null +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -0,0 +1,261 @@ +# `ocs_plugin_api` — Internal Architecture + +Internal-facing architecture documentation for `crates/ocs_plugin_api`. For plugin-author tutorials see [`README.md`](./README.md); for host/plugin integration design goals see [`../../docs/plugin-architecture.md`](../../docs/plugin-architecture.md). + +--- + +## Overview + +`ocs_plugin_api` is the stable, semver-versioned contract between the Open CAD Studio host and add-on plugins. The crate is intentionally split into two tiers: + +| Tier | Feature | Dependencies | Purpose | +|------|---------|--------------|---------| +| **Core** | default | none | `PluginManifest`, `ApiVersion`, ribbon vocabulary, type registry, version info. Engine crates and external tooling can depend on this cheaply. | +| **Runtime host surface** | `host` | `acadrust`, `interprocess`, `memmap2`, `rkyv`, etc. | `HostApi`, `BuiltinPlugin`, out-of-process spawn, IPC, shared-memory snapshots, runner. | + +The host and each plugin run in separate OS processes. The host re-executes itself in `--ocs-plugin-runner` mode, the runner dynamically loads the plugin `cdylib`, and the two sides talk over a local socket. Process isolation keeps plugin crashes from affecting the host or other plugins. + +--- + +## Module map + +| Module | Responsibility | +|--------|----------------| +| [`src/manifest.rs`](src/manifest.rs) | `API_VERSION`, `API_VERSION_MIN_SUPPORTED`, `ApiVersion` compatibility, runtime `OCS_PLUGIN_MAX_API_VERSION` gate. | +| [`src/ribbon`](src/ribbon) | `CadModule` trait and plain-data ribbon types (`RibbonGroup`, `ToolDef`, `IconKind`, `ModuleEvent`). | +| [`src/host.rs`](src/host.rs) | `HostApi` plugin-facing runtime trait, `BuiltinPlugin` entry-point trait, `export_plugin!` macro, `HostNotification`, `PluginNotification`. | +| [`src/ipc/mod.rs`](src/ipc/mod.rs) | IPC layer public exports. | +| [`src/ipc/protocol.rs`](src/ipc/protocol.rs) | V2/V3 request/response enums (`HostRequest`, `HostResponse`, `PluginRequest`, `PluginResponse`), `RunnerHandshake`, `PLUGIN_TOKEN_ENV`. | +| [`src/ipc/transport.rs`](src/ipc/transport.rs) | Length-framed bincode send/recv over `interprocess::local_socket::Stream`. | +| [`src/ipc/client.rs`](src/ipc/client.rs) | Plugin-side V2/V3 `IpcClient` and `PluginHostApi` proxy. | +| [`src/ipc/server.rs`](src/ipc/server.rs) | Host-side handler that applies a `PluginRequest` to a `HostApi` implementation. | +| [`src/ipc/v4`](src/ipc/v4) | Multiplexed V4 protocol: frames, notifications, correlation ids. | +| [`src/ipc/proxy.rs`](src/ipc/proxy.rs) | Optional TCP request proxy for plugin child processes. | +| [`src/process.rs`](src/process.rs) | `PluginProcess` lifecycle: spawn, handshake, call, timeouts, `NullHost`, io draining. | +| [`src/process/manager.rs`](src/process/manager.rs) | `PluginManager`: owns loaded plugins, dispatch routing, notification broadcast, V4 gating. | +| [`src/process/v4.rs`](src/process/v4.rs) | Host-side V4 connection and reader thread. | +| [`src/runner.rs`](src/runner.rs) | Runner entry point and V2/V3 vs V4 request loops; loads cdylib via `libloading`. | +| [`src/shm.rs`](src/shm.rs) | Shared-memory document snapshot layout, `DocumentSnapshotStore`, `SharedDocumentReader`, V3/V4 view data. | +| [`src/host_v4.rs`](src/host_v4.rs) | Host-side per-tab V4 snapshot manager. | +| [`src/type_registry.rs`](src/type_registry.rs) / [`src/type_registry_types.rs`](src/type_registry_types.rs) | Build-time `serde-reflection` registry, embedded JSON, schema types. | +| [`src/version_info.rs`](src/version_info.rs) | Embedded version metadata and acadrust source-compatibility helper. | + +--- + +## Versioning and ABI stability + +- `API_VERSION` (currently `4`) is the host's advertised major. +- `API_VERSION_MIN_SUPPORTED` (currently `2`) is the oldest plugin major the host loads. +- `OCS_PLUGIN_MAX_API_VERSION` can cap the accepted major at runtime (e.g. `3` to disable V4). +- A plugin built against major `N` runs on a host whose major is `>= N` because new vtable entries and enum variants are appended at the end. +- V4 introduces the **acadrust gate**: plugins targeting API v4 or later must resolve the same `acadrust` source as the host (see [`src/version_info.rs`](src/version_info.rs)). + +The runtime enforces two gates: + +1. `ocs_plugin_api_version()` exported by the cdylib must be within `[API_VERSION_MIN_SUPPORTED, effective_max_api_version()]`. +2. For v4+, `acadrust_sources_compatible(host_acadrust_source(), plugin_acadrust_source)` must be true. + +--- + +## Process model + +```mermaid +sequenceDiagram + participant H as Host process + participant PM as PluginManager + participant PP as PluginProcess + participant R as Runner child + participant L as Plugin cdylib + + PM->>PP: spawn(cdylib_path, host) + PP->>H: spawn --ocs-plugin-runner + H->>R: exec + R->>R: create local socket listener + R->>L: unsafe { load(cdylib_path) } + L-->>R: Box + R->>PP: connect + RunnerHandshake::Token + PP->>PP: verify OCS_PLUGIN_TOKEN + PP->>R: HostToPlugin::Request(GetManifest) + R->>L: manifest() + L-->>R: PluginManifest { api_version } + R-->>PP: HostResponse::Manifest + PP->>PP: verify api_version + acadrust gate + alt accepted + PP-->>PM: success, keep alive + else rejected + PP->>R: Shutdown + PP-->>PM: error + end +``` + +Key invariants: + +- The runner process is the host binary re-executed with special CLI args, so runner and host share the same `ocs_plugin_api` build. +- The plugin cdylib is `dlopen`-ed inside the runner, not the host. +- A pre-shared token (`OCS_PLUGIN_TOKEN`) authenticates the runner to the host. +- `PluginProcess` owns the `Child` handle and a reader thread; `PluginManager` owns the loaded plugins. + +--- + +## Wire protocols + +### V2/V3: request/response over a single socket + +A single bidirectional socket carries both directions. While the host waits for a response to a `HostRequest`, it may receive nested `PluginRequest`s from the runner and handles them inline. + +```mermaid +sequenceDiagram + participant H as Host + participant R as Runner + participant P as BuiltinPlugin + + H->>R: HostToPlugin::Request(Dispatch { cmd: "LINE" }) + R->>P: dispatch(host, "LINE") + P->>R: PluginToHost::Request(AddEntity(...)) + R->>H: HostToPlugin::Request(AddEntity(...)) + H-->>R: PluginToHost::Response(Handle) + R-->>P: PluginResponse::Handle + P-->>R: true + R-->>H: PluginToHost::Response(Bool(true)) +``` + +Compatibility rule: new variants are appended at the end of `HostRequest`, `HostResponse`, `PluginRequest`, and `PluginResponse`, preserving bincode discriminant indices for old plugins. + +### V4: multiplexed frames + +V4 keeps the V3 request vocabulary but multiplexes requests, responses, and best-effort notifications over one local socket. Every request/response carries a correlation `id`; notifications carry an optional `command_id`. + +```mermaid +sequenceDiagram + participant H as Host + participant R as V4 Runner + participant P as BuiltinPlugin + + H->>R: HostToPluginV4::Notification(DocumentChangedV4 { tab_id }) + H->>R: HostToPluginV4::Request { id: 7, ExecuteCode } + R->>P: start_execute_code(host, 7, ...) + P-->>R: true + P->>R: Notification(Output { text: "..." }) + R->>H: PluginToHostV4::Notification(Output) + P->>R: Response { id: 7, result } + R->>H: PluginToHostV4::Response { id: 7, CodeExecutionResult } +``` + +Key types in [`src/ipc/v4/protocol.rs`](src/ipc/v4/protocol.rs): + +- `HostToPluginV4`: `Request { id, HostRequest }`, `Response { id, PluginResponse }`, `Notification`. +- `PluginToHostV4`: `Request { id, tab_id, PluginRequest }`, `Response { id, HostResponse }`, `Notification`. +- `NotificationEnvelope`: `{ command_id: Option, payload: T }`. + +Notifications are best-effort: per-process errors are logged, not propagated. Unknown host-notification discriminants deserialize to `HostNotification::Unknown(raw)` so a newer host does not crash an older plugin. + +--- + +## Shared-memory document views + +V3 and V4 avoid cloning the full `CadDocument` over IPC for large reads. The host owns a memory-mapped file; the plugin maps it read-only. + +```mermaid +sequenceDiagram + participant P as Plugin + participant R as Runner + participant H as Host + participant S as DocumentSnapshotStore + + P->>R: request(OpenDocumentView) + R->>H: PluginRequest::OpenDocumentView + H->>S: DocumentSnapshotStore::new / publish + S-->>H: DocumentViewInfo { path, version } + H-->>R: PluginResponse::DocumentView { path, version } + R-->>P: DocumentViewInfo + P->>S: SharedDocumentReader::open(path) + S-->>P: read-only mapping + loop host publishes new version + H->>S: publish(data) + S->>S: swap active segment, increment version + P->>S: read() + check version + end + P->>P: close reader + H->>S: close(tab_id) +``` + +- [`src/shm.rs`](src/shm.rs) defines the generic double-buffered control page and the `SnapshotData` trait. Implementations live in the same file for V3 (`DocumentViewData`) and V4 (`DocumentViewDataV4`). +- [`src/host_v4.rs`](src/host_v4.rs) keeps a global `HostV4SnapshotManager` keyed by `tab_id`, so each document tab has its own snapshot. +- Segment size defaults to 16 MiB and is configurable via `OCS_V4_SNAPSHOT_SEGMENT_SIZE` (minimum 1 MiB). + +--- + +## Notifications + +- **Host → plugin:** `HostNotification` in [`src/host.rs`](src/host.rs). V4-only delivery via `PluginManager::broadcast_notification`. Currently emitted examples: `DocumentChangedV4`, `SelectionChangedV4`, `DocumentTabClosed`. +- **Plugin → host:** `PluginNotification` in [`src/host.rs`](src/host.rs). Examples: `Output`, `Progress`, `Log`. Handler installed via `PluginManager::set_notification_handler`. + +Broadcasting is V4-gated inside `PluginManager::broadcast_notification`: V2/V3 processes are skipped. The handler runs on the V4 reader thread and must not block. + +--- + +## Error handling and timeouts + +| Kind | Default | Env var | Behavior | +|------|---------|---------|----------| +| Spawn / connect timeout | 30 s | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | Host aborts spawn, plugin is not loaded. | +| Per-call timeout | 30 s | `OCS_PLUGIN_CALL_TIMEOUT_SECS` | Host kills runner, plugin marked dead. Floor timeouts apply to `GetManifest`/`GetRibbon`, `Dispatch`, and interactive events. | +| Oversized message | — | — | Transport rejects messages > 64 MiB with `TransportError::TooLarge`. | +| Malformed message | — | — | `bincode` deserialize error; V4 reader logs and continues if possible. | +| Panic in plugin code | — | — | Caught by `catch_unwind` in the runner; converted to `HostResponse::Error`. | +| Dead runner | — | — | Detected via `try_wait` on next dispatch or ribbon rebuild; plugin dropped. | + +--- + +## Embedded metadata + +The crate embeds two JSON blobs at build time: + +- **Type registry** (`OUT_DIR/type_registry.json`) — a language-binding-friendly schema for a curated allow-list of `acadrust` types, generated by `serde-reflection` in `build.rs`. See [`src/type_registry.rs`](src/type_registry.rs). +- **Version info** (`OUT_DIR/version_info.json`) — host version, `ocs_plugin_api` version, `acadrust` version and source, API versions, build timestamp. See [`src/version_info.rs`](src/version_info.rs). + +Both are accessible without enabling the `host` feature. + +--- + +## Security / isolation boundaries + +- **Process isolation:** plugin code runs in a child process. +- **Token handshake:** `OCS_PLUGIN_TOKEN` is generated per spawn and checked before accepting the runner connection. +- **C-ABI export:** plugins expose only two symbols, `ocs_plugin_api_version` and `ocs_plugin_register`, defined by `export_plugin!`. +- **Message size cap:** 64 MiB per length-framed message. +- **Shared memory:** plugin maps snapshots read-only; the host controls publish/close. + +This is defense-in-depth, not a sandbox: plugins execute native code. + +--- + +## Failure modes + +| Scenario | Result | +|----------|--------| +| Plugin panics during dispatch | Runner catches, returns error response, process stays alive. | +| Call timeout | Host kills runner, plugin marked dead. | +| Spawn timeout or crash | Spawn fails; plugin not loaded; error surfaced in UI/logs. | +| Version mismatch | Host refuses plugin before running plugin code. | +| Acadrust source mismatch (v4+) | Host refuses plugin to avoid binary incompatibilities. | +| Unknown notification discriminant | Deserializes to `HostNotification::Unknown`; plugin can ignore. | + +--- + +## Open tasks + +- **Reduce broadcast notification clone/serialize overhead.** `PluginManager::broadcast_notification` clones the `HostNotification` (including its `Vec`) once per loaded V4 plugin, and `transport::send` re-serializes the payload into a new `Vec` per plugin. Fixing this properly requires either a public API change (`Arc>` in `HostNotification`) or a new bytes-oriented broadcast path in the IPC layer. It only matters with many plugins plus very large selections. + +--- + +## Cross-references + +- Plugin-author quick start: [`README.md`](./README.md) +- Host/plugin integration spec: [`../../docs/plugin-architecture.md`](../../docs/plugin-architecture.md) +- Plugin template: [`../../docs/plugin-template/`](../../docs/plugin-template/) +- Process spawn: [`src/process.rs`](src/process.rs) +- Process manager: [`src/process/manager.rs`](src/process/manager.rs) +- Runner loop: [`src/runner.rs`](src/runner.rs) +- V4 protocol: [`src/ipc/v4/protocol.rs`](src/ipc/v4/protocol.rs) +- Shared memory: [`src/shm.rs`](src/shm.rs) diff --git a/crates/ocs_plugin_api/README.md b/crates/ocs_plugin_api/README.md index 93951816..25021c8e 100644 --- a/crates/ocs_plugin_api/README.md +++ b/crates/ocs_plugin_api/README.md @@ -120,9 +120,10 @@ The frame format supports: - Bi-directional, best-effort notifications (`NotificationEnvelope`) Because the socket is full-duplex, the host can push -`HostNotification::DocumentChanged` or `InputLine` to a plugin at any time, and -the plugin can stream `PluginNotification::Output`/`Progress`/`Log` back while a -long command is still running. +`HostNotification::DocumentChangedV4`, `SelectionChangedV4`, or +`DocumentTabClosed` to a plugin at any time, and the plugin can stream +`PluginNotification::Output`/`Progress`/`Log` back while a long command is still +running. ```mermaid sequenceDiagram @@ -420,7 +421,8 @@ std::thread::spawn(move || { ### Further reading +- Internal architecture & invariants: [`ARCHITECTURE.md`](./ARCHITECTURE.md) +- Host/plugin integration spec: [`../../docs/plugin-architecture.md`](../../docs/plugin-architecture.md) - Plugin template: [`../../docs/plugin-template`](../../docs/plugin-template) - Plugin marketplace registry: [`../../plugins/README.md`](../../plugins/README.md) - REPL design notes: [`../DESIGN_REPL.md`](../DESIGN_REPL.md) -- Python REPL plugin: [`../ocs_python_repl/README.md`](../ocs_python_repl/README.md) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index eb2220b9..f146c23a 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -69,6 +69,8 @@ pub enum HostNotification { DocumentChangedV4 { tab_id: u64, version: u64 }, /// V4 tab closed notification. Discriminant 6. DocumentTabClosed { tab_id: u64 }, + /// V4 selection changed for a specific tab. Discriminant 7. + SelectionChangedV4 { tab_id: u64, handles: Vec }, /// Fallback for notification variants added in future minor revisions. /// Carries the raw bincode payload so an older peer can ignore it without /// failing deserialization. @@ -112,6 +114,13 @@ impl Serialize for HostNotification { bincode::serialize_into(&mut bytes, tab_id) .map_err(serde::ser::Error::custom)?; } + HostNotification::SelectionChangedV4 { tab_id, handles } => { + bytes.push(7); + bincode::serialize_into(&mut bytes, tab_id) + .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, handles) + .map_err(serde::ser::Error::custom)?; + } HostNotification::Unknown(raw) => bytes.extend_from_slice(raw), } bytes.serialize(serializer) @@ -146,6 +155,9 @@ impl<'de> Deserialize<'de> for HostNotification { 6 => bincode::deserialize(rest) .map(|tab_id| HostNotification::DocumentTabClosed { tab_id }) .map_err(serde::de::Error::custom), + 7 => bincode::deserialize(rest) + .map(|(tab_id, handles)| HostNotification::SelectionChangedV4 { tab_id, handles }) + .map_err(serde::de::Error::custom), _ => Ok(HostNotification::Unknown(bytes)), } } diff --git a/crates/ocs_plugin_api/src/host_v4.rs b/crates/ocs_plugin_api/src/host_v4.rs index c6341338..3674ea8f 100644 --- a/crates/ocs_plugin_api/src/host_v4.rs +++ b/crates/ocs_plugin_api/src/host_v4.rs @@ -3,6 +3,12 @@ //! Maintains a per-tab shared-memory document view keyed by the stable //! `DocumentTab.id`. The manager is used by `src/plugin/v4_support.rs` to //! open, publish, and close V4 snapshots independently of the V3 reader path. +//! +//! [`manager`] returns a global `Mutex`-guarded [`HostV4SnapshotManager`]. +//! [`HostV4SnapshotManager::open`] lazily creates a [`DocumentSnapshotStore`] for +//! a tab; [`HostV4SnapshotManager::publish`] updates an existing store; +//! [`HostV4SnapshotManager::close`] drops it. Segment size defaults to 16 MiB +//! and can be overridden with `OCS_V4_SNAPSHOT_SEGMENT_SIZE`. use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; diff --git a/crates/ocs_plugin_api/src/ipc/mod.rs b/crates/ocs_plugin_api/src/ipc/mod.rs index 5ea05e15..44e7e02e 100644 --- a/crates/ocs_plugin_api/src/ipc/mod.rs +++ b/crates/ocs_plugin_api/src/ipc/mod.rs @@ -2,6 +2,21 @@ //! //! Built only with the `host` feature because it needs `acadrust`-typed //! messages and the plugin runner binary. +//! +//! Submodules: +//! +//! - [`protocol`](crate::ipc::protocol) — V2/V3 request/response enums and the +//! initial runner handshake. +//! - [`transport`](crate::ipc::transport) — length-framed bincode over a local +//! socket; enforces `MAX_MESSAGE_SIZE`. +//! - [`client`](crate::ipc::client) — plugin-side V2/V3 client and `HostApi` +//! proxy. +//! - [`server`](crate::ipc::server) — host-side handler that applies a +//! `PluginRequest` to a `HostApi` implementation. +//! - [`v4`](crate::ipc::v4) — multiplexed V4 frames, notifications, and +//! correlation ids. +//! - [`proxy`](crate::ipc::proxy) — optional TCP proxy so plugin child +//! processes can forward requests to the host. #[cfg(feature = "host")] pub mod client; diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index c8735a30..5b494026 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -4,6 +4,16 @@ //! (expecting a response) or a response (to a previous request). This lets the //! host handle plugin RPCs inline while it waits for the result of a host→plugin //! request such as `Dispatch`, avoiding the need for two sockets or threads. +//! +//! Compatibility rule: new variants are appended at the end of every public enum +//! (`HostRequest`, `HostResponse`, `PluginRequest`, `PluginResponse`, +//! `RunnerHandshake`) so older plugins keep their bincode discriminant indices. +//! The V4 additions are a separate frame layer in [`crate::ipc::v4`] and do not +//! alter these enums. +//! +//! The pre-shared runner token is delivered through [`PLUGIN_TOKEN_ENV`] +//! (`OCS_PLUGIN_TOKEN`). The runner must present the same token immediately +//! after connecting or the host closes the connection. use serde::{Deserialize, Serialize}; diff --git a/crates/ocs_plugin_api/src/ipc/transport.rs b/crates/ocs_plugin_api/src/ipc/transport.rs index 69e42ce6..3cc1296d 100644 --- a/crates/ocs_plugin_api/src/ipc/transport.rs +++ b/crates/ocs_plugin_api/src/ipc/transport.rs @@ -1,4 +1,12 @@ //! Length-framed transport over `interprocess::local_socket` streams. +//! +//! Messages are serialized with `bincode`, prefixed by a little-endian `u64` +//! length, and sent over the stream. The receiver parses the length, bounds it +//! against [`MAX_MESSAGE_SIZE`], then deserializes the payload. +//! +//! [`send`] and [`recv`] are synchronous and block until the full frame is read +//! or the peer disconnects. `Disconnected` is returned on clean EOF; `TooLarge` +//! rejects messages that exceed 64 MiB to protect host/runner memory. use std::io::{Read, Write}; diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 6eda5605..a491a6eb 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -761,7 +761,6 @@ impl DocumentReader for EmptyDocumentReader { #[cfg(all(test, feature = "host"))] mod tests { use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::Mutex as StdMutex; use std::thread; use interprocess::local_socket::{ @@ -772,10 +771,9 @@ mod tests { use super::*; use crate::ipc::transport::recv; use crate::ipc::v4::protocol::{HostToPluginV4, PluginToHostV4}; + use crate::test_lock::ENV_LOCK; use acadrust::entities::Point; - static ENV_LOCK: StdMutex<()> = StdMutex::new(()); - fn unique_socket_name() -> String { static COUNTER: AtomicU64 = AtomicU64::new(0); let n = COUNTER.fetch_add(1, Ordering::Relaxed); diff --git a/crates/ocs_plugin_api/src/ipc/v4/mod.rs b/crates/ocs_plugin_api/src/ipc/v4/mod.rs index e67788bd..3a27ac7e 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/mod.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/mod.rs @@ -4,6 +4,15 @@ //! requests, responses, and best-effort notifications over a single local //! socket. The V3 files (`src/ipc/{protocol,client,server}.rs`) are left //! untouched; all V4 logic lives in this module. +//! +//! Submodules: +//! +//! - [`protocol`](crate::ipc::v4::protocol) — V4 wire frames, notification +//! envelopes, and correlation ids. +//! - [`client`](crate::ipc::v4::client) — plugin-side V4 client loop and +//! notification dispatch. +//! - [`server`](crate::ipc::v4::server) — host-side V4 connection and reader +//! thread. pub mod client; pub mod protocol; diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 2d339b5d..34e97c2e 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -1,4 +1,23 @@ //! V4 wire frames and envelope types. +//! +//! V4 replaces the simple request/response pipe with a multiplexed frame layer. +//! Every request and response carries a monotonically increasing correlation +//! `id` so a plugin can send/receive out of order. Notifications carry an +//! optional `command_id` so a plugin can associate a host notification with the +//! long-running command that caused it. +//! +//! The two frame enums are: +//! +//! - [`HostToPluginV4`] — host → runner requests, responses to runner requests, +//! and host notifications. +//! - [`PluginToHostV4`] — runner → host requests, responses to host requests, +//! and plugin notifications. +//! +//! Notifications are wrapped in [`NotificationEnvelope`] and are best-effort: +//! a process that fails to accept a notification is logged and skipped, but the +//! connection stays alive. Unknown host-notification discriminants deserialize +//! to [`crate::host::HostNotification::Unknown`] so newer host notifications do +//! not break older plugins. use serde::{Deserialize, Serialize}; diff --git a/crates/ocs_plugin_api/src/lib.rs b/crates/ocs_plugin_api/src/lib.rs index e721983e..9b430922 100644 --- a/crates/ocs_plugin_api/src/lib.rs +++ b/crates/ocs_plugin_api/src/lib.rs @@ -20,6 +20,17 @@ //! For binary compatibility, the host and every plugin must resolve the same //! `acadrust` source. The host does this via a `[patch.crates-io]` entry in //! `Cargo.toml`; out-of-tree plugins should copy that exact patch. +//! +//! For internal architecture (process model, wire protocols, versioning policy, +//! failure modes), see `ARCHITECTURE.md` in the crate root. The modules enabled +//! by the `host` feature are: +//! +//! - `host` — plugin/runtime traits and notification types. +//! - `host_v4` — per-tab shared-memory snapshot manager. +//! - `ipc` — transport, V2/V3 protocol, and V4 multiplexed protocol. +//! - `process` — `PluginProcess` and `PluginManager`. +//! - `runner` — child-process runner entry point. +//! - `shm` — shared-memory document views. pub mod manifest; pub mod ribbon; @@ -64,5 +75,15 @@ pub use type_registry::{ }; pub use version_info::get_embedded_version_info_json; +#[cfg(test)] +pub(crate) mod test_lock { + //! Shared lock for tests that mutate process environment variables. + //! + //! Environment variables are global mutable state. Any test that sets or + //! removes an env var must hold this lock for the duration of the mutation + //! so tests in other modules do not observe half-written state. + pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +} + #[cfg(feature = "host")] pub use process::{DispatchResult, PluginError, PluginManager, PluginProcess}; diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index 0d459341..39352e8a 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -1,4 +1,16 @@ -//! Plugin identity and capability declaration. +//! Plugin identity, API version constants, and compatibility rules. +//! +//! This module defines the host ABI major version (`API_VERSION`), the oldest +//! major the host still loads (`API_VERSION_MIN_SUPPORTED`), and the runtime +//! `OCS_PLUGIN_MAX_API_VERSION` gate that lets operators disable newer API +//! majors without rebuilding. +//! +//! Compatibility rule: a plugin compiled against major `N` runs on any host +//! whose major is `>= N`, because new vtable entries and enum variants are +//! appended at the end. `host_accepts_plugin_version` enforces the supported +//! range after applying the runtime cap. +//! +//! See `ARCHITECTURE.md` in the crate root for the full versioning policy. /// Host plugin API version. Bump when the host runtime surface breaks /// compatibility. v2 added `HostApi::start_interactive`. v3 changes @@ -56,12 +68,10 @@ pub fn host_accepts_plugin_version(plugin_major: u32) -> bool { #[cfg(test)] mod tests { - use std::sync::Mutex; + use crate::test_lock::ENV_LOCK; use super::*; - static ENV_LOCK: Mutex<()> = Mutex::new(()); - #[test] fn current_matches_const() { assert_eq!(ApiVersion::CURRENT.major, API_VERSION); diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index 9a26d958..7535af35 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -1,4 +1,22 @@ //! Process management for out-of-process plugins. +//! +//! This module defines the lifecycle of a single plugin process: +//! +//! 1. `PluginProcess::spawn` creates a local socket, spawns the host binary in +//! runner mode (`--ocs-plugin-runner `), and waits for the +//! runner to connect back. +//! 2. The runner presents a pre-shared token via [`crate::ipc::protocol::PLUGIN_TOKEN_ENV`]; +//! the host rejects the connection on mismatch. +//! 3. The host requests the manifest, checks `api_version` and (for v4+) the +//! acadrust source gate, then keeps the process alive. +//! 4. Host → plugin calls (`dispatch`, `execute_code`, interactive events) are +//! sent over the socket with a configurable per-call timeout. +//! 5. Stdout/stderr of the child are drained into `PluginIoLine` records for +//! logging and UI display. +//! +//! `NullHost` is a dummy `HostApi` implementation used for V4 paths that do not +//! supply a real host surface (e.g., interactive events). Timeouts and message +//! size limits are centralized here. use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; @@ -1119,6 +1137,7 @@ mod timeout_tests { }; use crate::ipc::transport::{recv, send}; use crate::ribbon::owned::OwnedPluginManifest; + use crate::test_lock::ENV_LOCK; use acadrust::xdata::ExtendedDataRecord; use acadrust::{CadDocument, EntityType, Handle}; use interprocess::local_socket::{ @@ -1130,8 +1149,6 @@ mod timeout_tests { use std::thread; use std::time::Instant; - static ENV_LOCK: StdMutex<()> = StdMutex::new(()); - struct EmptyReader; impl DocumentReader for EmptyReader { diff --git a/crates/ocs_plugin_api/src/process/manager.rs b/crates/ocs_plugin_api/src/process/manager.rs index d2fec1e4..003f6ff0 100644 --- a/crates/ocs_plugin_api/src/process/manager.rs +++ b/crates/ocs_plugin_api/src/process/manager.rs @@ -1,4 +1,19 @@ //! Process manager for out-of-process plugins. +//! +//! `PluginManager` is the host's owner of every loaded plugin process. It is +//! responsible for: +//! +//! - Spawning plugins via [`PluginProcess::spawn`] and building their ribbon +//! modules. +//! - Routing commands to plugins through [`PluginManager::dispatch`]. +//! - Broadcasting host notifications to every alive V4 plugin via +//! [`PluginManager::broadcast_notification`]. +//! - Surfacing dead plugins and per-plugin errors in [`DispatchResult`]. +//! +//! Notifications are V4-only: `broadcast_notification` skips non-V4 processes. +//! The plugin-to-host notification handler (set via +//! [`PluginManager::set_notification_handler`]) runs on the V4 reader thread +//! and must not block. For heavy work, forward the notification to a channel. use std::path::Path; use std::sync::Arc; diff --git a/crates/ocs_plugin_api/src/process/v4.rs b/crates/ocs_plugin_api/src/process/v4.rs index 9843c3bb..d2fc6015 100644 --- a/crates/ocs_plugin_api/src/process/v4.rs +++ b/crates/ocs_plugin_api/src/process/v4.rs @@ -517,7 +517,7 @@ mod tests { use std::sync::Mutex as StdMutex; - static ENV_LOCK: StdMutex<()> = StdMutex::new(()); + use crate::test_lock::ENV_LOCK; fn set_test_env() { std::env::set_var("OCS_PLUGIN_CALL_TIMEOUT_SECS", "2"); @@ -532,6 +532,13 @@ mod tests { #[test] fn execute_code_timeout_floor_is_at_least_60s_and_env_override() { let _guard = ENV_LOCK.lock().unwrap(); + let prev_call_timeout = std::env::var("OCS_PLUGIN_CALL_TIMEOUT_SECS").ok(); + let prev_test_floor = std::env::var("OCS_PLUGIN_TEST_FLOOR_SECS").ok(); + let prev_execute_timeout = std::env::var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS").ok(); + // Remove the test floor bypass and the call-timeout override so the + // ExecuteCode-specific floor (60 s) is actually exercised. + std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS"); + std::env::remove_var("OCS_PLUGIN_TEST_FLOOR_SECS"); std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"); assert!( request_timeout("ExecuteCode") >= Duration::from_secs(60), @@ -544,7 +551,19 @@ mod tests { Duration::from_secs(120), "env override should be respected" ); - std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"); + + match prev_call_timeout { + Some(v) => std::env::set_var("OCS_PLUGIN_CALL_TIMEOUT_SECS", v), + None => std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS"), + } + match prev_test_floor { + Some(v) => std::env::set_var("OCS_PLUGIN_TEST_FLOOR_SECS", v), + None => std::env::remove_var("OCS_PLUGIN_TEST_FLOOR_SECS"), + } + match prev_execute_timeout { + Some(v) => std::env::set_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS", v), + None => std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"), + } } #[test] diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index 6b4a60c6..03f0097f 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -4,6 +4,18 @@ //! (`--ocs-plugin-runner `). Keeping the runner code inside //! `ocs_plugin_api` means the host only needs to know the CLI contract, not the //! internal plugin-loading and IPC details. +//! +//! Entry points and loops: +//! +//! - [`run`] — loads the cdylib with `libloading`, connects back to the host, +//! and dispatches to either the V2/V3 loop or the V4 loop based on the plugin's +//! declared API version. +//! - `run_v3` — synchronous request/response loop over [`crate::ipc::client::IpcClient`]. +//! - `run_v4` — multiplexed frame loop over [`crate::ipc::v4::client::V4Client`]; +//! drains notifications and dispatches host requests. +//! +//! Panics inside plugin callbacks are caught by `catch_unwind` and converted to +//! error responses so a buggy plugin callback does not tear down the runner. use std::cell::RefCell; use std::collections::HashMap; diff --git a/crates/ocs_plugin_api/src/shm.rs b/crates/ocs_plugin_api/src/shm.rs index ebada4e5..4203ce40 100644 --- a/crates/ocs_plugin_api/src/shm.rs +++ b/crates/ocs_plugin_api/src/shm.rs @@ -8,6 +8,21 @@ //! This module is generic over the payload type so that the V3 simplified view //! and the V4 bincode-per-entity view can share the same double-buffered //! control-page logic. +//! +//! Key types: +//! +//! - [`DocumentSnapshotStore`] — host-side, file-backed double buffer. Call +//! [`DocumentSnapshotStore::publish`] to atomically swap the active segment and +//! increment the version. +//! - [`SharedDocumentReader`] — plugin-side read-only mapping. +//! - [`DocumentViewInfo`] — path + version returned to the plugin so it can +//! open the mapping. +//! - [`DocumentViewData`] / [`DocumentViewDataV4`] — concrete snapshot payloads +//! for V3 and V4. +//! +//! The control page at the start of the mapping stores magic, version, active +//! segment, and length. The two snapshot segments follow it. Publishing writes +//! to the inactive segment, then flips the active segment atomically. use std::fs::OpenOptions; use std::io; diff --git a/crates/ocs_plugin_api/src/type_registry.rs b/crates/ocs_plugin_api/src/type_registry.rs index a563d715..ce55abce 100644 --- a/crates/ocs_plugin_api/src/type_registry.rs +++ b/crates/ocs_plugin_api/src/type_registry.rs @@ -3,7 +3,15 @@ //! The JSON embedded here is produced by tracing a curated allow-list of //! `acadrust` types with `serde-reflection` and mapping the result into a //! stable, language-binding-friendly schema defined in -//! [`crate::type_registry_types`]. +//! [`crate::type_registry_types`]. The registry is embedded via +//! `include_str!(concat!(env!("OUT_DIR"), "/type_registry.json"))` so the +//! `host` feature is not required to read it. +//! +//! The allow-list is intentionally narrow: it excludes generic containers and +//! internal enums such as `EntityType` in favor of concrete serializable structs +//! like `Point`, `Line`, `Circle`, and `MText`. Language bindings and tooling +//! can depend on this schema without pulling the full `acadrust` dependency +//! tree. pub use crate::type_registry_types::*; diff --git a/crates/ocs_plugin_api/src/version_info.rs b/crates/ocs_plugin_api/src/version_info.rs index 1df0bc30..35c870cd 100644 --- a/crates/ocs_plugin_api/src/version_info.rs +++ b/crates/ocs_plugin_api/src/version_info.rs @@ -1,4 +1,16 @@ //! Embedded version metadata generated at build time. +//! +//! At compile time `build.rs` writes a small JSON blob to +//! `OUT_DIR/version_info.json` containing host version, `ocs_plugin_api` +//! version, `acadrust` version and source, API version bounds, and a build +//! timestamp. The blob is embedded via `include_str!` and is available without +//! enabling the `host` feature. +//! +//! The acadrust source string is used to detect binary-incompatible plugin +//! builds. For API v4 and later the host compares the plugin's resolved +//! `acadrust` source with its own via [`acadrust_sources_compatible`]. Two +//! sources are considered compatible only when they resolve to the same 40 +//! character git commit hash. use std::sync::OnceLock; diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index b3a5e5a4..dc1a8d0a 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -127,6 +127,7 @@ concrete types: | Command line | `push_info`, `push_output`, `push_error` | | Undo / dirty | `push_undo`, `set_dirty` | | Tab | `tab_index()` | +| Notifications (V4) | `on_notification` receives `HostNotification::SelectionChangedV4 { tab_id, handles }` when the active tab's selection changes, plus `DocumentChangedV4` / `DocumentTabClosed`. | ### `export_plugin!` — the C-ABI export @@ -309,7 +310,7 @@ Two timeouts protect the host from a stuck runner: | Timeout | Env var | Default | Floor | |---|---|---|---| -| Spawn (connection) | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | 10 s | — | +| Spawn (connection) | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | 30 s | — | | Per-call | `OCS_PLUGIN_CALL_TIMEOUT_SECS` | 30 s | `GetManifest`/`GetRibbon` ≥ 5 s, `Dispatch` ≥ 10 s, interactive events/prompt/pick ≥ 2 s | A call timeout covers the full round-trip, including any nested plugin→host @@ -402,6 +403,7 @@ Next: | Piece | Location | |-------|----------| | Contract crate + runtime | [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) | +| Internal API architecture | [`crates/ocs_plugin_api/ARCHITECTURE.md`](../crates/ocs_plugin_api/ARCHITECTURE.md) | | Plugin runner implementation | [`crates/ocs_plugin_api/src/runner.rs`](../crates/ocs_plugin_api/src/runner.rs) | | Host spawn logic | [`crates/ocs_plugin_api/src/process.rs`](../crates/ocs_plugin_api/src/process.rs) | | Host plugin integration | `src/plugin/`, `src/app/plugin_host.rs` | diff --git a/src/app/automation.rs b/src/app/automation.rs index f72c3048..fddac688 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -193,6 +193,17 @@ fn entity_json(e: &acadrust::EntityType) -> Value { impl OpenCADStudio { /// Handle one JSON request line and return the JSON response. pub(crate) fn automation_op(&mut self, line: &str) -> Value { + let res = self.automation_op_inner(line); + // Most automation ops mutate `Scene::selected` directly rather than + // going through `update()` (`select` calls `deselect_all` / + // `select_entity`, and `run` can erase the selected entities), so the + // selection check has to run on this path too. + #[cfg(not(target_arch = "wasm32"))] + self.notify_plugins_selection_changed(); + res + } + + fn automation_op_inner(&mut self, line: &str) -> Value { let req: Value = match serde_json::from_str(line) { Ok(v) => v, Err(e) => return err(format!("invalid JSON: {e}")), @@ -201,6 +212,7 @@ impl OpenCADStudio { "new" => { let i = self.active_tab; self.tabs[i].scene.document = acadrust::CadDocument::new(); + self.tabs[i].scene.deselect_all(); self.tabs[i].current_path = None; // The headless session starts on the welcome (Start) tab, which // blocks drawing commands; turn it into a real drawing. @@ -224,6 +236,7 @@ impl OpenCADStudio { Ok(doc) => { let i = self.active_tab; self.tabs[i].scene.document = doc; + self.tabs[i].scene.deselect_all(); crate::app::style_ops::ensure_standard_styles( &mut self.tabs[i].scene.document, ); diff --git a/src/app/mod.rs b/src/app/mod.rs index 3830c21d..a82dc50e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -679,6 +679,10 @@ pub(super) struct OpenCADStudio { /// keep their manifest listed but drop their ribbon tab and command /// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`]. disabled_plugins: rustc_hash::FxHashSet, + /// `(tab id, selection signature)` last broadcast to V4 plugins, so + /// `SelectionChangedV4` fires once per real change rather than per message. + #[cfg(not(target_arch = "wasm32"))] + last_plugin_selection: Option<(u64, u64)>, /// External add-on packages found in the plugins folder, refreshed when the /// Plugin Manager opens. external_plugins: Vec, @@ -3222,6 +3226,8 @@ impl OpenCADStudio { attr_editor_tab: crate::ui::window::attribute_editor::AttrTab::Attribute, attr_editor_selected: 0, disabled_plugins: rustc_hash::FxHashSet::default(), + #[cfg(not(target_arch = "wasm32"))] + last_plugin_selection: None, external_plugins: Vec::new(), loaded_plugin_ids: rustc_hash::FxHashSet::default(), plugin_load_errors: rustc_hash::FxHashMap::default(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 6fbd26db..2a947376 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -231,6 +231,31 @@ impl OpenCADStudio { } } + /// Emit `SelectionChangedV4` to V4 plugins when the active tab's selection + /// set actually changed since the last broadcast. + /// + /// Checked at the message boundary rather than at the mutation sites so + /// every path is covered: picking, window select, QSELECT, SELECTALL, + /// grip edits that drop the selection, and the automation `select` op. + /// The signature comparison keeps hover and repeated no-op selects from + /// producing spurious notifications. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn notify_plugins_selection_changed(&mut self) { + if self.active_tab >= self.tabs.len() { + return; + } + let i = self.active_tab; + let tab_id = self.tabs[i].id; + let sig = self.tabs[i].scene.selection_sig(); + let key = (tab_id, sig); + if self.last_plugin_selection == Some(key) { + return; + } + self.last_plugin_selection = Some(key); + let handles = self.tabs[i].scene.selected_handles_in_order(); + crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles); + } + pub fn update(&mut self, msg: Message) -> Task { let perf_started = crate::perf::enabled().then(Instant::now); let perf_label = perf_message_label(&msg); @@ -275,6 +300,11 @@ impl OpenCADStudio { // The block panel watches the drawing's block list and rebuilds its // thumbnails whenever the names change (BLOCK define, file open, …). self.refresh_block_palette_if_stale(); + // Let V4 plugins observe selection changes that happened while handling + // this message (picking, window select, QSELECT, SELECTALL, grip edits, + // and plugin request draining). + #[cfg(not(target_arch = "wasm32"))] + self.notify_plugins_selection_changed(); // OTRACK acquires tracking points only while a command or grip drag is // running; drop them once neither is active so the temporary tracking // points / vectors disappear when the command ends (issue #64). diff --git a/src/plugin/v4_support.rs b/src/plugin/v4_support.rs index 23b7d205..59259f0f 100644 --- a/src/plugin/v4_support.rs +++ b/src/plugin/v4_support.rs @@ -42,6 +42,15 @@ pub fn on_tab_closed(tab_id: u64) { broadcast(HostNotification::DocumentTabClosed { tab_id }); } +/// Broadcast a selection change to V4 plugins. +/// +/// `HostNotification::SelectionChangedV4` carries the active tab id and the +/// current selection set. The caller is responsible for only calling this when +/// the selection actually changed; see `OpenCADStudio::notify_plugins_selection_changed`. +pub fn publish_selection_changed_v4(tab_id: u64, handles: Vec) { + broadcast(HostNotification::SelectionChangedV4 { tab_id, handles }); +} + /// V4 notification handler installed on the plugin manager. /// /// Forwards REPL status messages to the log; other notifications are handled diff --git a/src/scene/selection.rs b/src/scene/selection.rs index af107d9c..195e071e 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -1,6 +1,9 @@ // Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged. use super::*; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + impl Scene { // ── Selection ───────────────────────────────────────────────────────── /// Treat a classic LEADER and its attached annotation as one logical object. @@ -57,6 +60,21 @@ impl Scene { self.bump_selection(); } + /// Order-independent signature of the current selection set. + /// + /// `selection_generation` cannot be used for change detection because it + /// also bumps on hover. This signature combines a per-handle hash with XOR + /// so it is allocation-free, O(n), and insensitive to selection order. + pub(crate) fn selection_sig(&self) -> u64 { + let mut combined = 0u64; + for handle in &self.selected { + let mut hasher = DefaultHasher::new(); + handle.hash(&mut hasher); + combined ^= hasher.finish(); + } + combined + } + pub(crate) fn selected_handles_in_order(&self) -> Vec { let mut seen = HashSet::default(); let mut ordered: Vec<_> = self @@ -838,3 +856,31 @@ impl Scene { restored } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selection_sig_is_order_independent() { + let mut scene_a = Scene::default(); + let mut scene_b = Scene::default(); + let h1 = Handle::new(1); + let h2 = Handle::new(2); + scene_a.select_entity(h1, false); + scene_a.select_entity(h2, false); + scene_b.select_entity(h2, false); + scene_b.select_entity(h1, false); + assert_eq!(scene_a.selection_sig(), scene_b.selection_sig()); + } + + #[test] + fn selection_sig_changes_when_selection_changes() { + let mut scene = Scene::default(); + let empty_sig = scene.selection_sig(); + scene.select_entity(Handle::new(1), false); + assert_ne!(scene.selection_sig(), empty_sig); + scene.deselect_all(); + assert_eq!(scene.selection_sig(), empty_sig); + } +} From 5c125f96be00dca14358cfe6aa38aeb7b14e1d5d Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:49:51 +0200 Subject: [PATCH 02/20] docs(plugin): remove superseded ocs_plugin_api/REPORT.md The out-of-process plugin design proposal in REPORT.md has been implemented and evolved to API v4. The current architecture is documented in ARCHITECTURE.md. REPORT.md is unreferenced and no longer needed; its content remains in git history for reference. --- crates/ocs_plugin_api/REPORT.md | 193 -------------------------------- 1 file changed, 193 deletions(-) delete mode 100644 crates/ocs_plugin_api/REPORT.md diff --git a/crates/ocs_plugin_api/REPORT.md b/crates/ocs_plugin_api/REPORT.md deleted file mode 100644 index 1509fd1c..00000000 --- a/crates/ocs_plugin_api/REPORT.md +++ /dev/null @@ -1,193 +0,0 @@ -# Out-of-Process Plugin Architecture — `ocs_plugin_api` - -**Status:** Design proposal -**Scope:** `crates/ocs_plugin_api` only (minimal host wiring, no plugin changes, no new crate) - ---- - -## 1. Problem - -The host currently loads external add-ons as `cdylib` libraries into its own process via `libloading`. `panic::catch_unwind` catches Rust panics, but a plugin can still corrupt host memory, segfault, or deadlock the UI thread. The fix is to run each plugin as a separate OS process and mediate all interaction through IPC. - ---- - -## 2. Design - -`ocs_plugin_api` becomes a dual-use library: - -- **Plugin side:** unchanged API surface (`BuiltinPlugin`, `HostApi`, `CadModule`, `export_plugin!`). -- **Host side:** runtime that spawns plugin processes and handles their IPC requests. - -Plugins remain `cdylib`s. The host spawns **itself** in runner mode (`--ocs-plugin-runner `) to load each cdylib in a child process and bridge to the host over `interprocess::local_socket`. The runner implementation lives inside `ocs_plugin_api` as a library module; no separate helper binary is needed, so the runner and host are always the same build and cannot get out of sync at deployment time. - ---- - -## 3. Constraints - -| Constraint | Handling | -|---|---| -| Plugin API unchanged | Trait/type signatures preserved. `document()` / `document_mut()` keep their signatures but return a local cached copy, so `API_VERSION` bumps to 3. | -| Only `ocs_plugin_api` modified | All new code lives here. The host needs only minimal call-site wiring in `src/plugin/external.rs`, `src/plugin/registry.rs`, and `src/app/plugin_host.rs`. | -| No new crate | Runner code lives inside `ocs_plugin_api`; the host executable serves as the runner process. | -| Platform-independent | `interprocess::local_socket` uses named pipes on Windows and Unix domain sockets elsewhere; self-spawning works on every host target. | - ---- - -## 4. Architecture - -```text -Host process Plugin process -┌─────────────────┐ local socket ┌─────────────────┐ -│ HostSession │◄───────────────►│ HostApi proxy │ -│ (document, UI) │ bincode frames │ (sends RPCs) │ -└─────────────────┘ └─────────────────┘ - ▲ │ - │ ▼ - PluginManager cdylib loaded - (spawn / kill / by host in - supervise) runner mode -``` - -### 4.1 IPC protocol - -All messages are length-framed and serialized with `bincode`. - -**Host → plugin:** - -- `GetManifest`, `GetRibbon` -- `Dispatch { cmd: String }` -- `InteractiveEvent { command_id, event }` -- `Shutdown` - -**Plugin → host:** - -- `PushInfo` / `PushOutput` / `PushError` -- `AddEntity(SerializedEntity)` → `Handle` -- `BumpGeometry`, `PushUndo`, `SetDirty` -- `ReadRecord`, `WriteRecord`, `RemoveRecord` -- `StartInteractive`, `PollInteractive` -- `DocumentSnapshot` → `SerializedDocument` - -`SerializedEntity`, `SerializedRecord`, and `SerializedDocument` are `acadrust` types with `Serialize` / `Deserialize` derived. - -### 4.2 Plugin-side runtime - -`OpenCADStudio --ocs-plugin-runner `: - -1. Loads the cdylib, validates `ocs_plugin_api_version`, calls `ocs_plugin_register`. -2. Connects to the host socket and answers `GetManifest` / `GetRibbon`. -3. Runs a request loop: dispatch commands, forward interactive events. - -`PluginHostApi` implements `HostApi` by sending RPCs. `document()` / `document_mut()` return a local copy fetched from `DocumentSnapshot`; mutations are **not** automatically synced back. Plugins use `add_entity`, `write_record`, etc. for host-visible changes. - -### 4.3 Host-side runtime - -`ocs_plugin_api::process` provides: - -- `PluginProcess::spawn(cdylib_path)` — creates a socket, launches the host executable in runner mode, accepts its connection. -- `PluginManager` — spawn all discovered plugins, supervise, kill. -- `serve_plugin_connection(stream, &mut dyn HostApi)` — host-side request handler. - -The host creates a `HostSession` and passes it to `serve_plugin_connection`; existing document/undo logic is reused. - -### 4.4 Ribbon - -Ribbon types use `&'static str`, which cannot cross a socket. Define owned equivalents in `ocs_plugin_api::ribbon::owned` and convert for IPC. The host reconstructs `RibbonGroup` once per load (e.g., by leaking the owned strings), avoiding changes to ribbon rendering code. - -### 4.5 Failure handling - -| Failure | Behavior | -|---|---| -| Plugin crash / hang / malformed message | Host marks plugin dead, drops its ribbon tab, logs the error, and continues running. | -| Plugin panics | Caught inside the runner; an error response is returned to the host. | -| Spawn failure | Reported through `PluginManager` and shown in the Plugin Manager. | - ---- - -## 5. Host Integration Points - -1. **`src/plugin/external.rs`** — replace `libloading`-based `LoadedPlugin` with `PluginProcess::spawn`. -2. **`src/plugin/registry.rs`** — use `PluginProcess` for ribbon collection and command dispatch. -3. **`src/app/plugin_host.rs`** — add an IPC request bridge that maps incoming messages to `HostSession` calls. - -No changes to `docs/plugin-template` or any other plugin. - ---- - -## 6. Crate Changes - -### 6.1 New files inside `crates/ocs_plugin_api` - -```text -src/ - ipc/ - protocol.rs # HostRequest / PluginRequest / PluginResponse - transport.rs # framed read/write over local_socket - client.rs # plugin-side IpcClient + PluginHostApi - server.rs # host-side serve_plugin_connection - process.rs # PluginProcess / PluginManager - runner.rs # plugin runner logic invoked by host in runner mode -``` - -### 6.2 Dependencies - -Add under the existing `host` feature: - -```toml -[dependencies] -interprocess = { version = "2", optional = true } -serde = { version = "1", features = ["derive"], optional = true } -bincode = { version = "1", optional = true } -thiserror = { version = "1", optional = true } -libloading = { version = "0.8", optional = true } - -[features] -host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading"] -``` - ---- - -## 7. API Version - -Bump `API_VERSION` to `3` because `document()` / `document_mut()` semantics change from direct host references to local cached copies. v2 plugins are refused as usual; plugin authors recompile with `ApiVersion::CURRENT`. - ---- - -## 8. Implementation Plan - -1. Add dependencies to `Cargo.toml`. -2. Implement framed transport and protocol messages. -3. Implement runner logic in `runner.rs`. -4. Implement `PluginHostApi` proxy. -5. Implement host-side server and `PluginManager`. -6. Add owned ribbon conversions. -7. Wire the three host call sites and add `--ocs-plugin-runner` dispatch in `src/main.rs`. -8. Bump `API_VERSION` and update `docs/plugin-architecture.md`. - ---- - -## 9. Testing - -- **Unit:** protocol round-trip, ribbon conversion, proxy request emission. -- **Integration:** spawn a test plugin, verify dispatch and interactive command round-trip, kill the process and confirm the host survives. -- **Host:** update registry tests once `LoadedPlugin` is replaced by `PluginProcess`. - ---- - -## 10. Compliance with `AGENT.md` - -| Requirement | Status | -|---|---| -| `ocs_plugin_api` is a library, not a plugin | Yes | -| Plugin API source-compatible | Yes; signatures are unchanged. `document()` semantics change is gated by v3. | -| Separate processes + failure management | Yes | -| Platform-independent `interprocess` IPC | Yes | -| Only `ocs_plugin_api` modified | Code: yes. Host needs minimal wiring; unavoidable because `OpenCADStudio` / `HostSession` are host-private. | -| No new crate | Yes | -| Memory / process isolation | Yes | - ---- - -## 11. Summary - -`ocs_plugin_api` absorbs the plugin runtime: the host spawns itself in runner mode to load each cdylib in its own process, and all host/plugin interaction is serialized over `interprocess` local sockets. Plugin API signatures stay intact, host changes are limited to a few call sites, and no new crate is introduced. From fff811577beb3cbe5de6e52dc7bde63b18d0e2dd Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:50:15 +0300 Subject: [PATCH 03/20] feat(text): complete single-line creation workflow --- src/app/command_driver.rs | 16 ++ src/app/commands/draw.rs | 9 +- src/app/text_inline.rs | 44 +++- src/command.rs | 8 + src/modules/annotate/text.rs | 407 +++++++++++++++++++++++++++++++++-- 5 files changed, 449 insertions(+), 35 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index bd57247d..8dda1246 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3748,6 +3748,22 @@ impl OpenCADStudio { &initial, height, super::text_inline::TextEntityField::Text, + None, + ); + } + CmdResult::SuspendForTextInput { pos, entity } => { + self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take(); + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + let height = entity.height; + self.open_text_inline( + pos, + None, + "", + height, + super::text_inline::TextEntityField::Text, + Some(entity), ); } CmdResult::EditTextEntity { handle } => { diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 065f2e62..4c2b3814 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1268,11 +1268,10 @@ impl OpenCADStudio { // ── Annotate commands ────────────────────────────────────────── "TEXT" => { use crate::modules::annotate::text::TextCommand; - let height = crate::scene::creation_style::current_text_defaults( - &self.tabs[i].scene.document, - ) - .height; - let new_cmd = TextCommand::with_height(height); + let document = &self.tabs[i].scene.document; + let defaults = crate::scene::creation_style::current_text_defaults(document); + let styles = document.text_styles.iter().cloned().collect(); + let new_cmd = TextCommand::with_defaults(defaults, styles); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs index c1f8b495..b08297fc 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -95,6 +95,9 @@ pub struct TextInlineState { pub editing: Option, /// Which entity slot this session writes to on commit. pub field: TextEntityField, + /// Fully prepared entity supplied by the interactive TEXT command. Editing + /// existing text and legacy direct-open paths leave this empty. + pub creation: Option, /// Canvas-space anchor where the field is drawn (the insertion-point click). pub screen_anchor: iced::Point, } @@ -169,7 +172,7 @@ impl super::OpenCADStudio { self.open_mtext_editor(pos, Some(target), &value, height); self.unfocus_widgets() } else { - self.open_text_inline(pos, Some(target), &value, height, field); + self.open_text_inline(pos, Some(target), &value, height, field, None); iced::widget::operation::focus(iced::widget::Id::new(super::view::TEXT_INLINE_ID)) } } @@ -183,6 +186,7 @@ impl super::OpenCADStudio { initial: &str, height: f64, field: TextEntityField, + creation: Option, ) { if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) { return; @@ -193,6 +197,7 @@ impl super::OpenCADStudio { height: if height > 0.0 { height } else { 0.25 }, editing: handle, field, + creation, screen_anchor: iced::Point::new(60.0, 90.0), }; if let Some(p) = self.tabs[self.active_tab].scene.selection.borrow().last_move_pos { @@ -229,12 +234,17 @@ impl super::OpenCADStudio { } else { crate::command::WorkingPlane::default() }; - let position = plane.to_local(ed.pos); - let mut t = Text::with_value( - &ed.value, - Vector3::new(position.x, position.y, position.z), - ) - .with_height(ed.height); + let mut t = if let Some(mut prepared) = ed.creation { + prepared.value = ed.value.clone(); + prepared + } else { + let position = plane.to_local(ed.pos); + Text::with_value( + &ed.value, + Vector3::new(position.x, position.y, position.z), + ) + .with_height(ed.height) + }; // New text inherits the document's current text style (STYLE), not // the entity default. See #92. let cur_style = self.tabs[i] @@ -244,13 +254,31 @@ impl super::OpenCADStudio { .current_text_style_name .clone(); if !cur_style.is_empty() { - t.style = cur_style; + if t.style.trim().is_empty() { + t.style = cur_style; + } } let annotative = crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &t.style, ); self.push_undo_snapshot(i, "TEXT"); + self.tabs[i].scene.document.header.current_text_style_name = t.style.clone(); + let variable_height = self.tabs[i] + .scene + .document + .text_styles + .iter() + .find(|style| style.name.eq_ignore_ascii_case(&t.style)) + .is_none_or(|style| style.height <= 1.0e-9); + if variable_height + && !matches!( + t.horizontal_alignment, + acadrust::entities::TextHorizontalAlignment::Aligned + ) + { + self.tabs[i].scene.document.header.text_height = t.height; + } let handle = self.commit_entity_handle(plane.place_entity(EntityType::Text(t))); if annotative { let scale = self.tabs[i].scene.current_annotation_scale_handle(); diff --git a/src/command.rs b/src/command.rs index 46cad343..42d81896 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1443,6 +1443,14 @@ pub enum CmdResult { initial: String, height: f64, }, + /// Suspend the active TEXT command while the in-place editor collects one + /// independent line. The prepared entity carries the chosen style, + /// justification, rotation and two-point geometry. When the editor closes, + /// the command resumes so another line can be placed directly below it. + SuspendForTextInput { + pos: DVec3, + entity: acadrust::entities::Text, + }, /// Apply new pattern/scale/angle to an existing hatch entity. HatcheditApply { handle: Handle, diff --git a/src/modules/annotate/text.rs b/src/modules/annotate/text.rs index c00ad981..041394cb 100644 --- a/src/modules/annotate/text.rs +++ b/src/modules/annotate/text.rs @@ -1,7 +1,14 @@ -use crate::command::{CadCommand, CmdResult}; -use crate::modules::{IconKind, ModuleEvent, ToolDef}; -use crate::scene::model::wire_model::WireModel; +use acadrust::entities::{ + Text, TextHorizontalAlignment as HA, TextVerticalAlignment as VA, +}; +use acadrust::tables::TextStyle; +use acadrust::types::Vector3; use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::creation_style::TextCreationDefaults; +use crate::scene::model::wire_model::WireModel; use crate::t; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/text.svg")); @@ -15,57 +22,413 @@ pub fn tool() -> ToolDef { } } +#[derive(Clone, Copy, PartialEq, Eq)] enum Step { - InsertPoint, + Start, + Justification, + Style, + Height, + Rotation, + SecondPoint, } pub struct TextCommand { step: Step, + plane: WorkingPlane, + first_point: Option, + second_point: Option, + horizontal: HA, + vertical: VA, + style_name: String, + styles: Vec, height: f64, + rotation: f64, + width_factor: f64, + oblique_angle: f64, + fixed_height: bool, + annotative: bool, + generation_flags: i16, + last_entity: Option, } impl TextCommand { - pub fn with_height(height: f64) -> Self { - Self { - step: Step::InsertPoint, - height, + pub fn with_defaults(defaults: TextCreationDefaults, styles: Vec) -> Self { + let current_height = defaults.height; + let mut command = Self { + step: Step::Start, + plane: WorkingPlane::default(), + first_point: None, + second_point: None, + horizontal: HA::Left, + vertical: VA::Baseline, + style_name: defaults.style_name, + styles, + height: defaults.height, + rotation: 0.0, + width_factor: defaults.width_factor, + oblique_angle: defaults.oblique_angle, + fixed_height: false, + annotative: false, + generation_flags: 0, + last_entity: None, + }; + let style = command.style_name.clone(); + command.select_style(&style); + if !command.fixed_height { + command.height = current_height; } + command + } + + fn select_style(&mut self, name: &str) -> bool { + let Some(style) = self + .styles + .iter() + .find(|style| style.name.eq_ignore_ascii_case(name)) + .cloned() + else { + return false; + }; + self.style_name = style.name; + self.fixed_height = style.height > 1.0e-9; + if self.fixed_height { + self.height = style.height; + } else if style.last_height > 1.0e-9 { + self.height = style.last_height; + } + self.width_factor = style.width_factor.max(0.01); + self.oblique_angle = style.oblique_angle.clamp( + -85.0_f64.to_radians(), + 85.0_f64.to_radians(), + ); + self.annotative = style.annotative; + self.generation_flags = (if style.flags.backward { 2 } else { 0 }) + | (if style.flags.upside_down { 4 } else { 0 }); + true + } + + fn set_justification(&mut self, value: &str) -> bool { + let normalized = value.trim().to_ascii_uppercase().replace([' ', '-'], ""); + let alignment = match normalized.as_str() { + "L" | "LEFT" => (HA::Left, VA::Baseline), + "C" | "CENTER" => (HA::Center, VA::Baseline), + "R" | "RIGHT" => (HA::Right, VA::Baseline), + "A" | "ALIGNED" | "ALIGN" => (HA::Aligned, VA::Baseline), + "M" | "MIDDLE" => (HA::Middle, VA::Baseline), + "F" | "FIT" => (HA::Fit, VA::Baseline), + "TL" | "TOPLEFT" => (HA::Left, VA::Top), + "TC" | "TOPCENTER" => (HA::Center, VA::Top), + "TR" | "TOPRIGHT" => (HA::Right, VA::Top), + "ML" | "MIDDLELEFT" => (HA::Left, VA::Middle), + "MC" | "MIDDLECENTER" => (HA::Center, VA::Middle), + "MR" | "MIDDLERIGHT" => (HA::Right, VA::Middle), + "BL" | "BOTTOMLEFT" => (HA::Left, VA::Bottom), + "BC" | "BOTTOMCENTER" => (HA::Center, VA::Bottom), + "BR" | "BOTTOMRIGHT" => (HA::Right, VA::Bottom), + _ => return false, + }; + self.horizontal = alignment.0; + self.vertical = alignment.1; + true + } + + fn is_two_point(&self) -> bool { + matches!(self.horizontal, HA::Aligned | HA::Fit) + } + + fn after_first_point(&mut self) -> CmdResult { + if self.is_two_point() { + self.step = Step::SecondPoint; + } else if self.fixed_height { + self.step = Step::Rotation; + } else { + self.step = Step::Height; + } + CmdResult::NeedPoint + } + + fn after_second_point(&mut self) -> CmdResult { + if matches!(self.horizontal, HA::Fit) && !self.fixed_height { + self.step = Step::Height; + CmdResult::NeedPoint + } else { + self.open_editor() + } + } + + fn make_entity(&self) -> Option { + let first = self.plane.to_local(self.first_point?); + let mut text = Text::with_value("", Vector3::new(first.x, first.y, first.z)) + .with_height(self.height.max(1.0e-9)); + text.style = self.style_name.clone(); + text.width_factor = self.width_factor.max(0.01); + text.oblique_angle = self.oblique_angle; + text.rotation = self.rotation; + text.horizontal_alignment = self.horizontal; + text.vertical_alignment = self.vertical; + text.generation_flags = self.generation_flags; + text.alignment_point = if self.is_two_point() { + let second = self.plane.to_local(self.second_point?); + Some(Vector3::new(second.x, second.y, second.z)) + } else if matches!((self.horizontal, self.vertical), (HA::Left, VA::Baseline)) { + None + } else { + Some(Vector3::new(first.x, first.y, first.z)) + }; + Some(text) + } + + fn open_editor(&mut self) -> CmdResult { + let Some(entity) = self.make_entity() else { + return CmdResult::NeedPoint; + }; + let pos = self.first_point.unwrap_or(DVec3::ZERO); + self.last_entity = Some(entity.clone()); + CmdResult::SuspendForTextInput { pos, entity } + } + + fn open_next_line(&mut self) -> CmdResult { + let Some(mut entity) = self.last_entity.take() else { + return CmdResult::Cancel; + }; + let angle = if matches!(entity.horizontal_alignment, HA::Aligned | HA::Fit) { + entity.alignment_point.map_or(entity.rotation, |point| { + (point.y - entity.insertion_point.y) + .atan2(point.x - entity.insertion_point.x) + }) + } else { + entity.rotation + }; + let spacing = entity.height.max(1.0e-9) * 1.666_666_666_7; + let delta = Vector3::new(angle.sin() * spacing, -angle.cos() * spacing, 0.0); + entity.insertion_point = entity.insertion_point + delta; + if let Some(point) = entity.alignment_point.as_mut() { + *point = *point + delta; + } + entity.value.clear(); + let local = DVec3::new( + entity.insertion_point.x, + entity.insertion_point.y, + entity.insertion_point.z, + ); + let pos = self.plane.to_world(local); + self.last_entity = Some(entity.clone()); + CmdResult::SuspendForTextInput { pos, entity } } } impl CadCommand for TextCommand { + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + fn name(&self) -> &'static str { "TEXT" } fn prompt(&self) -> String { - match &self.step { - Step::InsertPoint => t!("TEXT Specify insertion point:").into_owned(), + match self.step { + Step::Start => format!( + "{}\n{}", + crate::tf!( + "TEXT Current style: {}, Height: {}, Annotative: {}", + self.style_name, + self.height, + if self.annotative { "Yes" } else { "No" } + ), + t!("TEXT Specify start point or [Justify/Style]:") + ), + Step::Justification => t!( + "TEXT Enter justification [Left/Center/Right/Aligned/Middle/Fit/TL/TC/TR/ML/MC/MR/BL/BC/BR]:" + ) + .into_owned(), + Step::Style => crate::tf!("TEXT Enter style name <{}>:", self.style_name).into_owned(), + Step::Height if self.annotative => { + crate::tf!("TEXT Specify paper text height <{}>:", self.height).into_owned() + } + Step::Height => crate::tf!("TEXT Specify height <{}>:", self.height).into_owned(), + Step::Rotation => crate::tf!( + "TEXT Specify rotation angle <{}>:", + self.rotation.to_degrees() + ) + .into_owned(), + Step::SecondPoint => t!("TEXT Specify second endpoint:").into_owned(), } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - // Hand off to the in-place plain-text editor anchored at the click. - CmdResult::OpenTextEditor { - pos: pt, - handle: None, - initial: String::new(), - height: self.height, + fn options(&self) -> Vec { + match self.step { + Step::Start => vec![ + CmdOption::new(t!("Justify").as_ref(), "J"), + CmdOption::new(t!("Style").as_ref(), "ST"), + ], + Step::Justification => [ + ("Left", "L"), ("Center", "C"), ("Right", "R"), + ("Aligned", "A"), ("Middle", "M"), ("Fit", "F"), + ("TL", "TL"), ("TC", "TC"), ("TR", "TR"), + ("ML", "ML"), ("MC", "MC"), ("MR", "MR"), + ("BL", "BL"), ("BC", "BC"), ("BR", "BR"), + ] + .into_iter() + .map(|(label, keyword)| CmdOption::new(label, keyword)) + .collect(), + Step::Style => self + .styles + .iter() + .map(|style| CmdOption::new(&style.name, &style.name)) + .collect(), + _ => Vec::new(), + } + } + + fn wants_text_input(&self) -> bool { + matches!( + self.step, + Step::Justification | Step::Style | Step::Height | Step::Rotation + ) + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::Start) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let token = text.trim(); + let upper = token.to_ascii_uppercase(); + match self.step { + Step::Start => match upper.as_str() { + "J" | "JUSTIFY" | "JUSTIFICATION" => self.step = Step::Justification, + "S" | "ST" | "STYLE" => self.step = Step::Style, + _ => return None, + }, + Step::Justification => { + if !self.set_justification(token) { + return None; + } + self.step = Step::Start; + } + Step::Style => { + if !self.select_style(token) { + return None; + } + self.step = Step::Start; + } + Step::Height => { + let value = token.replace(',', ".").parse::().ok()?; + if !value.is_finite() || value <= 1.0e-9 { + return None; + } + self.height = value; + if self.is_two_point() { + return Some(self.open_editor()); + } + self.step = Step::Rotation; + } + Step::Rotation => { + let value = token.replace(',', ".").parse::().ok()?; + if !value.is_finite() { + return None; + } + self.rotation = value.to_radians(); + return Some(self.open_editor()); + } + Step::SecondPoint => return None, + } + Some(CmdResult::NeedPoint) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::Start => { + self.first_point = Some(point); + self.second_point = None; + self.after_first_point() + } + Step::Height => { + let Some(first) = self.first_point else { + return CmdResult::NeedPoint; + }; + let value = self + .plane + .vector_to_local(point - first) + .truncate() + .length(); + if value <= 1.0e-9 { + return CmdResult::NeedPoint; + } + self.height = value; + if self.is_two_point() { + self.open_editor() + } else { + self.step = Step::Rotation; + CmdResult::NeedPoint + } + } + Step::Rotation => { + let Some(first) = self.first_point else { + return CmdResult::NeedPoint; + }; + let Some(angle) = self.plane.angle(first, point) else { + return CmdResult::NeedPoint; + }; + self.rotation = angle; + self.open_editor() + } + Step::SecondPoint => { + let Some(first) = self.first_point else { + return CmdResult::NeedPoint; + }; + if self + .plane + .vector_to_local(point - first) + .truncate() + .length() + <= 1.0e-9 + { + return CmdResult::NeedPoint; + } + self.second_point = Some(point); + self.after_second_point() + } + Step::Justification | Step::Style => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + Step::Start => CmdResult::Cancel, + Step::Justification | Step::Style => { + self.step = Step::Start; + CmdResult::NeedPoint + } + Step::Height => { + if self.is_two_point() { + self.open_editor() + } else { + self.step = Step::Rotation; + CmdResult::NeedPoint + } + } + Step::Rotation => self.open_editor(), + Step::SecondPoint => CmdResult::NeedPoint, + } } + + fn on_editor_closed(&mut self, committed: bool) -> CmdResult { + if committed { + self.open_next_line() + } else { + CmdResult::Cancel + } + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } - fn on_mouse_move(&mut self, _pt: DVec3) -> Option { + fn on_mouse_move(&mut self, _point: DVec3) -> Option { None } } - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] }); // TextCommand +inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] }); From 3e9fcaed7c935b33a474a8902567688a79dc3afd Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:50:21 +0300 Subject: [PATCH 04/20] fix(properties): align single-line text fields --- src/app/properties.rs | 54 +++++++++++++++++++++++++++++++++++ src/scene/cache/properties.rs | 4 ++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index bd7daa3c..43d3d463 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1799,6 +1799,60 @@ impl OpenCADStudio { } } + // Single-line text height rows depend on both the + // justification and the active annotation scale. Aligned + // text derives its paper height from the two endpoints, + // while annotative text exposes that paper height and a + // separate calculated model height. + if let acadrust::EntityType::Text(text) = entity { + let aligned = matches!( + text.horizontal_alignment, + acadrust::entities::TextHorizontalAlignment::Aligned + ); + let paper_height = if aligned { + crate::entities::text::text_run_placement(text, doc).height as f64 + } else { + text.height + }; + let annotative = crate::scene::annotative::is_annotative(doc, entity); + for section in sections.iter_mut() { + if let Some(row) = + section.props.iter_mut().find(|row| row.field == "height") + { + if annotative { + row.label = t!("Paper text height").into_owned(); + } + if aligned { + row.value = crate::scene::model::object::PropValue::ReadOnly( + crate::entities::common::format_length(paper_height), + ); + } + } + } + if annotative { + let model_factor = annotation_scale_handle + .and_then(|handle| match doc.objects.get(&handle) { + Some(acadrust::objects::ObjectType::Scale(scale)) => Some( + scale.inverse_factor() + / self.tabs[i].scene.annotation_scale_unit_factor(), + ), + _ => None, + }) + .unwrap_or(self.tabs[i].scene.annotation_scale as f64); + insert_row_after( + &mut sections, + "height", + crate::entities::common::ro_prop( + t!("Model text height").as_ref(), + "model_text_height", + crate::entities::common::format_length( + paper_height * model_factor, + ), + ), + ); + } + } + if !group_names.is_empty() { let label = group_names.join(", "); if let Some(general) = sections.first_mut() { diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index fe199e88..67078b2c 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -94,7 +94,9 @@ pub fn general_section(entity: &EntityType) -> PropSection { ], }; - if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) { + if matches!(entity, EntityType::Text(_)) + || matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) + { section.props.retain(|prop| prop.field != "handle"); } From c00d08d40af99ccb876160f905b3996e4844f31d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:50:31 +0300 Subject: [PATCH 05/20] fix(text): honor two-point geometry and grips --- src/app/update/command.rs | 7 +- src/entities/text.rs | 160 ++++++++++++++++++++++++++++++-------- 2 files changed, 134 insertions(+), 33 deletions(-) diff --git a/src/app/update/command.rs b/src/app/update/command.rs index d6c48ab6..8b8e2ea0 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1162,6 +1162,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if matches!( item.action, GripMenuAction::Stretch + | GripMenuAction::MoveWithText | GripMenuAction::MoveWithDimLine | GripMenuAction::MoveWithLeader | GripMenuAction::MoveIndependent @@ -1212,7 +1213,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { { (crate::entities::multileader::MOVE_ALL_GRIP, true) } else { - (popup.grip_id, if is_dimension { false } else { g.is_midpoint }) + ( + popup.grip_id, + matches!(item.action, GripMenuAction::MoveWithText) + || (!is_dimension && g.is_midpoint), + ) }; self.tabs[i].active_grip = Some(GripEdit::single( popup.handle, diff --git a/src/entities/text.rs b/src/entities/text.rs index 18a96fda..fd0b69ea 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -43,7 +43,24 @@ pub(crate) fn sync_text_alignment_point(t: &mut Text) { (HA::Left, VA::Baseline) ); if needs_alignment_point { - if t.alignment_point.is_none() { + if matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) { + let point = t.alignment_point.unwrap_or(t.insertion_point); + let dx = point.x - t.insertion_point.x; + let dy = point.y - t.insertion_point.y; + if dx.hypot(dy) <= 1.0e-9 { + let span = t.height.max(1.0e-6) + * t.width_factor.abs().max(0.01) + * t.value.chars().count().max(1) as f64 + * 0.6; + t.alignment_point = Some(acadrust::types::Vector3::new( + t.insertion_point.x + t.rotation.cos() * span, + t.insertion_point.y + t.rotation.sin() * span, + t.insertion_point.z, + )); + } else { + t.alignment_point = Some(point); + } + } else if t.alignment_point.is_none() { t.alignment_point = Some(t.insertion_point); } } else { @@ -51,6 +68,21 @@ pub(crate) fn sync_text_alignment_point(t: &mut Text) { } } +fn two_point_span(t: &Text) -> Option<(f64, f64)> { + if !matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) { + return None; + } + let point = t.alignment_point?; + let dx = point.x - t.insertion_point.x; + let dy = point.y - t.insertion_point.y; + let distance = dx.hypot(dy); + (distance > 1.0e-9).then(|| (distance, dy.atan2(dx))) +} + +fn displayed_rotation(t: &Text) -> f64 { + two_point_span(t).map_or(t.rotation, |(_, angle)| angle) +} + /// Resolved placement of a TEXT run: the baseline-anchored run origin (WCS xy) /// plus every parameter needed to lay the glyphs out. Shared by `to_render` (the /// stroke path) and the SDF-quad text collector so both place text identically. @@ -152,10 +184,9 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla ); let resolved_style = resolve_text_style(&t.style, document); let font_name = resolved_style.font_name; - // AutoCAD text geometry rule: the entity stores the FINAL width factor / - // oblique angle, copied from the style at creation and persisting through - // style edits. Use it as-is. Only fall back to the style when the entity - // value is missing (the parser reports 0.0 for default-omitted fields). + // The entity stores the final width factor and oblique angle copied from + // its style at creation. Only fall back to the style when an omitted field + // was read as zero. let base_wf = if t.width_factor.abs() > 1e-9 { (t.width_factor as f32).clamp(0.01, 100.0) } else { @@ -168,17 +199,43 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla // mirror, and XOR keeps a double mirror an involution. let eff_backward = resolved_style.is_backward ^ (t.generation_flags & 0x2 != 0); let eff_upside = resolved_style.is_upside_down ^ (t.generation_flags & 0x4 != 0); - let width_factor = if eff_backward { -base_wf } else { base_wf }; - let rotation = if eff_upside { - t.rotation as f32 + std::f32::consts::PI - } else { - t.rotation as f32 - }; + let mut width_factor = if eff_backward { -base_wf } else { base_wf }; let oblique_angle = if t.oblique_angle.abs() > 1e-9 { t.oblique_angle as f32 } else { resolved_style.oblique_angle }; + let value_for_bounds = resolve_dxf_special_chars(&t.value); + let mut height = t.height.max(1.0e-9) as f32; + let mut base_rotation = t.rotation as f32; + + // Aligned and Fit are true two-point modes. Both derive their baseline + // direction from the endpoints. Aligned scales height uniformly; Fit keeps + // the height and changes only the horizontal factor. + if let Some((span, angle)) = two_point_span(t) { + base_rotation = angle as f32; + if let Some(base_bounds) = text_local_bounds( + &font_name, + &value_for_bounds, + height, + width_factor, + oblique_angle, + ) { + if base_bounds.advance > 1.0e-6 { + let scale = (span as f32 / base_bounds.advance).max(1.0e-6); + if matches!(t.horizontal_alignment, HA::Aligned) { + height *= scale; + } else { + width_factor *= scale; + } + } + } + } + let rotation = if eff_upside { + base_rotation + std::f32::consts::PI + } else { + base_rotation + }; // Anchor stays f64: large coordinates (UTM etc.) lose ~0.5 units of // precision when cast to f32, which snaps text baselines onto a coarse // grid and makes adjacent rows collide. Only the small local offsets @@ -188,17 +245,16 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla &t.vertical_alignment, &t.alignment_point, ) { - (HA::Aligned | HA::Middle | HA::Fit, _, Some(a)) => [a.x, a.y], + (HA::Aligned | HA::Fit, _, _) => [t.insertion_point.x, t.insertion_point.y], + (HA::Middle, _, Some(a)) => [a.x, a.y], (HA::Center | HA::Right, _, Some(a)) => [a.x, a.y], (_, VA::Bottom | VA::Middle | VA::Top, Some(a)) => [a.x, a.y], _ => [t.insertion_point.x, t.insertion_point.y], }; - // Strip %%u/%%o for bounds (they add no width); resolve %%d/%%c/%%p for correct advance. - let value_for_bounds = resolve_dxf_special_chars(&t.value); let bounds = text_local_bounds( &font_name, &value_for_bounds, - t.height as f32, + height, width_factor, oblique_angle, ); @@ -213,7 +269,8 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla let ax = match t.horizontal_alignment { HA::Left => 0.0, HA::Center | HA::Middle => b.advance * 0.5 * sign, - HA::Right | HA::Aligned | HA::Fit => b.advance * sign, + HA::Right => b.advance * sign, + HA::Aligned | HA::Fit => 0.0, }; // Vertical anchor uses the inked extent (cap / baseline geometry). let ay = match t.vertical_alignment { @@ -236,7 +293,7 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla ]; TextPlacement { origin, - height: t.height as f32, + height, rotation, width_factor, oblique_angle, @@ -247,12 +304,19 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla } fn grips(t: &Text) -> Vec { - let p = glam::DVec3::new( + let insertion = glam::DVec3::new( t.insertion_point.x, t.insertion_point.y, t.insertion_point.z, ); - vec![square_grip(0, p)] + let mut grips = vec![square_grip(0, insertion)]; + if let Some(point) = t.alignment_point { + grips.push(square_grip( + 1, + glam::DVec3::new(point.x, point.y, point.z), + )); + } + grips } fn properties(t: &Text, text_style_names: &[String]) -> Vec { @@ -263,6 +327,8 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec { // both points are live. let is_plain_left = matches!(t.horizontal_alignment, HA::Left) && matches!(t.vertical_alignment, VA::Baseline); + let is_aligned = matches!(t.horizontal_alignment, HA::Aligned); + let is_two_point = matches!(t.horizontal_alignment, HA::Aligned | HA::Fit); let pos_editable = is_plain_left || matches!(t.horizontal_alignment, HA::Aligned | HA::Fit); let align_editable = !is_plain_left; // The alignment point is meaningless (reset to the origin) for plain-Left text. @@ -331,9 +397,18 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec { t!("Height").as_ref(), "height", t.height, - crate::entities::common::style_fixed_height(&t.style).is_none(), + !is_aligned + && crate::entities::common::style_fixed_height(&t.style).is_none(), ), - edit_angle(t!("Rotation").as_ref(), "rotation", t.rotation.to_degrees()), + if is_two_point { + ro( + t!("Rotation").as_ref(), + "rotation", + crate::entities::common::format_angle(displayed_rotation(t)), + ) + } else { + edit_angle(t!("Rotation").as_ref(), "rotation", t.rotation.to_degrees()) + }, edit(t!("Width factor").as_ref(), "width_factor", t.width_factor), edit_angle(t!("Obliquing").as_ref(), "oblique_angle", t.oblique_angle.to_degrees()), num_row(t!("Text alignment X").as_ref(), "align_x", ax, align_editable), @@ -451,25 +526,44 @@ fn apply_geom_prop(t: &mut Text, field: &str, value: &str) { _ => ap.z = v, } } - "height" if v > 0.0 => t.height = v, - "rotation" => t.rotation = v.to_radians(), + "height" + if v > 0.0 && !matches!(t.horizontal_alignment, HA::Aligned) => + { + t.height = v + } + "rotation" if !matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) => { + t.rotation = v.to_radians() + } "width_factor" if v > 0.0 => t.width_factor = v, - "oblique_angle" => t.oblique_angle = v.to_radians(), + "oblique_angle" if (-85.0..=85.0).contains(&v) => { + t.oblique_angle = v.to_radians() + } _ => {} } } -fn apply_grip(t: &mut Text, _grip_id: usize, apply: GripApply) { +fn apply_grip(t: &mut Text, grip_id: usize, apply: GripApply) { match apply { GripApply::Absolute(p) => { - t.insertion_point.x = p.x as f64; - t.insertion_point.y = p.y as f64; - t.insertion_point.z = p.z as f64; + let target = if grip_id == 1 { + let insertion = t.insertion_point; + t.alignment_point.get_or_insert(insertion) + } else { + &mut t.insertion_point + }; + target.x = p.x; + target.y = p.y; + target.z = p.z; } GripApply::Translate(d) => { - t.insertion_point.x += d.x as f64; - t.insertion_point.y += d.y as f64; - t.insertion_point.z += d.z as f64; + t.insertion_point.x += d.x; + t.insertion_point.y += d.y; + t.insertion_point.z += d.z; + if let Some(point) = t.alignment_point.as_mut() { + point.x += d.x; + point.y += d.y; + point.z += d.z; + } } } } @@ -551,7 +645,9 @@ impl Grippable for Text { value: f64, ) { use crate::scene::model::object::GripMenuAction as A; - if matches!(action, A::RotateText) { + if matches!(action, A::RotateText) + && !matches!(self.horizontal_alignment, HA::Aligned | HA::Fit) + { self.rotation = value.to_radians(); } } From 0f5f47b37d5dad1b603cc88dc69251799ec604f5 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:05:07 +0300 Subject: [PATCH 06/20] fix(properties): correct text position editability --- src/entities/text.rs | 54 ++++++++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/src/entities/text.rs b/src/entities/text.rs index fd0b69ea..48e1ca0e 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -329,15 +329,23 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec { && matches!(t.vertical_alignment, VA::Baseline); let is_aligned = matches!(t.horizontal_alignment, HA::Aligned); let is_two_point = matches!(t.horizontal_alignment, HA::Aligned | HA::Fit); - let pos_editable = is_plain_left || matches!(t.horizontal_alignment, HA::Aligned | HA::Fit); - let align_editable = !is_plain_left; - // The alignment point is meaningless (reset to the origin) for plain-Left text. + // The visible Properties palette exposes Text alignment as calculated + // coordinates and Position as the editable placement point. For aligned + // and fit text Position targets the first endpoint; for every other + // non-left mode it targets the alignment anchor that actually places the + // glyph run. + let position_uses_alignment = !is_plain_left && !is_two_point; let ap = t.alignment_point.unwrap_or(t.insertion_point); - let (ax, ay, az) = if align_editable { + let (ax, ay, az) = if !is_plain_left { (ap.x, ap.y, ap.z) } else { (0.0, 0.0, 0.0) }; + let position = if position_uses_alignment { + ap + } else { + t.insertion_point + }; vec![ PropSection { title: t!("Text").into_owned(), @@ -411,17 +419,17 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec { }, edit(t!("Width factor").as_ref(), "width_factor", t.width_factor), edit_angle(t!("Obliquing").as_ref(), "oblique_angle", t.oblique_angle.to_degrees()), - num_row(t!("Text alignment X").as_ref(), "align_x", ax, align_editable), - num_row(t!("Text alignment Y").as_ref(), "align_y", ay, align_editable), - num_row(t!("Text alignment Z").as_ref(), "align_z", az, align_editable), + num_row(t!("Text alignment X").as_ref(), "align_x", ax, false), + num_row(t!("Text alignment Y").as_ref(), "align_y", ay, false), + num_row(t!("Text alignment Z").as_ref(), "align_z", az, false), ], }, PropSection { title: t!("Geometry").into_owned(), props: vec![ - num_row(t!("Position X").as_ref(), "ins_x", t.insertion_point.x, pos_editable), - num_row(t!("Position Y").as_ref(), "ins_y", t.insertion_point.y, pos_editable), - num_row(t!("Position Z").as_ref(), "ins_z", t.insertion_point.z, pos_editable), + num_row(t!("Position X").as_ref(), "ins_x", position.x, true), + num_row(t!("Position Y").as_ref(), "ins_y", position.y, true), + num_row(t!("Position Z").as_ref(), "ins_z", position.z, true), ], }, PropSection { @@ -514,18 +522,26 @@ fn apply_geom_prop(t: &mut Text, field: &str, value: &str) { return; }; match field { - "ins_x" => t.insertion_point.x = v, - "ins_y" => t.insertion_point.y = v, - "ins_z" => t.insertion_point.z = v, - "align_x" | "align_y" | "align_z" => { - let ins = t.insertion_point; - let ap = t.alignment_point.get_or_insert(ins); + "ins_x" | "ins_y" | "ins_z" => { + let plain_left = matches!(t.horizontal_alignment, HA::Left) + && matches!(t.vertical_alignment, VA::Baseline); + let two_point = matches!(t.horizontal_alignment, HA::Aligned | HA::Fit); + let target = if !plain_left && !two_point { + let insertion = t.insertion_point; + t.alignment_point.get_or_insert(insertion) + } else { + &mut t.insertion_point + }; match field { - "align_x" => ap.x = v, - "align_y" => ap.y = v, - _ => ap.z = v, + "ins_x" => target.x = v, + "ins_y" => target.y = v, + _ => target.z = v, } } + "align_x" | "align_y" | "align_z" => { + // Calculated display rows are intentionally not writable. + return; + } "height" if v > 0.0 && !matches!(t.horizontal_alignment, HA::Aligned) => { From 4be01774e17f49933e7d8f48e11952b1c0abd4fd Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:21 +0300 Subject: [PATCH 07/20] Complete MText boundary creation flow --- src/app/command_driver.rs | 5 +- src/app/commands/draw.rs | 8 +- src/app/text_inline.rs | 2 +- src/command.rs | 5 + src/modules/annotate/mtext.rs | 334 ++++++++++++++++++++++++++++++++-- 5 files changed, 330 insertions(+), 24 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index bd57247d..ea345814 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3716,10 +3716,11 @@ impl OpenCADStudio { handle, initial, height, + template, } => { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; - self.open_mtext_editor(pos, handle, &initial, height); + self.open_mtext_editor(pos, handle, &initial, height, template.map(|m| *m)); } CmdResult::SuspendForMTextInput { pos, @@ -3732,7 +3733,7 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); self.command_mtext_input = true; self.pending_command_editor_text = None; - self.open_mtext_editor(pos, None, &initial, height); + self.open_mtext_editor(pos, None, &initial, height, None); } CmdResult::OpenTextEditor { pos, diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 065f2e62..1fab3abf 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1307,7 +1307,13 @@ impl OpenCADStudio { &self.tabs[i].scene.document, ) .height; - let new_cmd = MTextCommand::with_height(height); + let style = self.tabs[i] + .scene + .document + .header + .current_text_style_name + .clone(); + let new_cmd = MTextCommand::with_defaults(height, style); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs index c1f8b495..5a110088 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -166,7 +166,7 @@ impl super::OpenCADStudio { }; if field.is_rich() { - self.open_mtext_editor(pos, Some(target), &value, height); + self.open_mtext_editor(pos, Some(target), &value, height, None); self.unfocus_widgets() } else { self.open_text_inline(pos, Some(target), &value, height, field); diff --git a/src/command.rs b/src/command.rs index 46cad343..50d17df8 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1427,6 +1427,11 @@ pub enum CmdResult { handle: Option, initial: String, height: f64, + /// Optional entity defaults collected by the interactive MTEXT + /// command (boundary, rotation, attachment, spacing and columns). + /// Existing-entity edits leave this as `None` and load the document + /// entity instead. + template: Option>, }, /// Collect rich text without creating an MText entity. SuspendForMTextInput { diff --git a/src/modules/annotate/mtext.rs b/src/modules/annotate/mtext.rs index a6a6d6d6..5a1f838a 100644 --- a/src/modules/annotate/mtext.rs +++ b/src/modules/annotate/mtext.rs @@ -1,7 +1,11 @@ -use crate::command::{CadCommand, CmdResult}; +use acadrust::entities::mtext::AttachmentPoint; +use acadrust::types::Vector3; +use acadrust::MText; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, DynField, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; -use glam::DVec3; use crate::t; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/mtext.svg")); @@ -15,20 +19,136 @@ pub fn tool() -> ToolDef { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Step { - InsertPoint, + FirstCorner, + OppositeCorner, + Height, + Justify, + LineSpacing, + Rotation, + Style, + Width, + ColumnMode, + ColumnCount, + ColumnWidth, + ColumnGutter, } pub struct MTextCommand { step: Step, + first: Option, + plane: WorkingPlane, height: f64, + style: String, + attachment: AttachmentPoint, + line_spacing: f64, + rotation: f64, + width: Option, + column_type: i16, + column_count: i32, + column_width: f64, + column_gutter: f64, } impl MTextCommand { - pub fn with_height(height: f64) -> Self { + pub fn with_defaults(height: f64, style: String) -> Self { Self { - step: Step::InsertPoint, - height, + step: Step::FirstCorner, + first: None, + plane: WorkingPlane::default(), + height: height.max(1e-6), + style, + attachment: AttachmentPoint::TopLeft, + line_spacing: 1.0, + rotation: 0.0, + width: None, + column_type: 0, + column_count: 2, + column_width: 0.0, + column_gutter: 0.0, + } + } + + fn resume_point_step(&mut self) { + self.step = if self.first.is_some() { + Step::OppositeCorner + } else { + Step::FirstCorner + }; + } + + fn point_options() -> Vec { + vec![ + CmdOption::new("Height", "HEIGHT"), + CmdOption::new("Justify", "JUSTIFY"), + CmdOption::new("Line spacing", "LINESPACING"), + CmdOption::new("Rotation", "ROTATION"), + CmdOption::new("Style", "STYLE"), + CmdOption::new("Width", "WIDTH"), + CmdOption::new("Columns", "COLUMNS"), + ] + } + + fn begin_option(&mut self, keyword: &str) -> bool { + self.step = match keyword { + "H" | "HEIGHT" => Step::Height, + "J" | "JUSTIFY" => Step::Justify, + "L" | "LINESPACING" | "LINE SPACING" => Step::LineSpacing, + "R" | "ROTATION" => Step::Rotation, + "S" | "STYLE" => Step::Style, + "W" | "WIDTH" => Step::Width, + "C" | "COLUMNS" => Step::ColumnMode, + _ => return false, + }; + true + } + + fn boundary_size(&self, opposite: DVec3) -> (f64, f64) { + let first = self.first.unwrap_or(opposite); + let local = self.plane.vector_to_local(opposite - first); + let (sin, cos) = self.rotation.sin_cos(); + let horizontal = local.x * cos + local.y * sin; + let vertical = -local.x * sin + local.y * cos; + (self.width.unwrap_or(horizontal.abs()), vertical.abs()) + } + + fn open_editor(&self, opposite: DVec3) -> CmdResult { + let first = self.first.unwrap_or(opposite); + let (width, boundary_height) = self.boundary_size(opposite); + let mut template = MText::default(); + template.insertion_point = Vector3::new(first.x, first.y, first.z); + template.normal = Vector3::new(self.plane.z.x, self.plane.z.y, self.plane.z.z); + template.height = self.height; + template.rectangle_width = width.max(0.0); + template.rectangle_height = (boundary_height > 1e-9).then_some(boundary_height); + template.rotation = self.rotation; + template.style = self.style.clone(); + template.attachment_point = self.attachment; + template.line_spacing_factor = self.line_spacing; + template.column_data.column_type = self.column_type; + if self.column_type != 0 { + template.column_data.column_count = self.column_count.max(1); + template.column_data.auto_height = self.column_type == 2; + template.column_data.width = if self.column_width > 0.0 { + self.column_width + } else if width > 0.0 { + width + } else { + self.height * 10.0 + }; + template.column_data.gutter = if self.column_gutter > 0.0 { + self.column_gutter + } else { + self.height + }; + } + CmdResult::OpenMTextEditor { + pos: first, + handle: None, + initial: String::new(), + height: self.height, + template: Some(Box::new(template)), } } } @@ -38,34 +158,208 @@ impl CadCommand for MTextCommand { "MTEXT" } + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + fn prompt(&self) -> String { - match &self.step { - Step::InsertPoint => t!("MTEXT Specify insertion point:").into_owned(), + match self.step { + Step::FirstCorner => t!("MTEXT Specify first corner:").into_owned(), + Step::OppositeCorner => t!("MTEXT Specify opposite corner:").into_owned(), + Step::Height => crate::tf!("MTEXT Specify text height <{}>:", self.height).into_owned(), + Step::Justify => t!("MTEXT Enter justification [TL / TC / TR / ML / MC / MR / BL / BC / BR] :").into_owned(), + Step::LineSpacing => crate::tf!("MTEXT Enter line spacing factor (0.25-4.00) <{}>:", self.line_spacing).into_owned(), + Step::Rotation => crate::tf!("MTEXT Specify rotation angle <{}>:", self.rotation.to_degrees()).into_owned(), + Step::Style => crate::tf!("MTEXT Enter text style <{}>:", self.style).into_owned(), + Step::Width => crate::tf!("MTEXT Specify boundary width <{}>:", self.width.unwrap_or(0.0)).into_owned(), + Step::ColumnMode => t!("MTEXT Columns [None / Static / Dynamic] :").into_owned(), + Step::ColumnCount => crate::tf!("MTEXT Enter column count <{}>:", self.column_count).into_owned(), + Step::ColumnWidth => crate::tf!("MTEXT Enter column width <{}>:", self.column_width).into_owned(), + Step::ColumnGutter => crate::tf!("MTEXT Enter column gutter <{}>:", self.column_gutter).into_owned(), } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - // Hand off to the in-place editor (toolbar + text area + live preview). - CmdResult::OpenMTextEditor { - pos: pt, - handle: None, - initial: String::new(), - height: self.height, + fn options(&self) -> Vec { + match self.step { + Step::FirstCorner | Step::OppositeCorner => Self::point_options(), + Step::Justify => ["TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"] + .into_iter() + .map(|value| CmdOption::new(value, value)) + .collect(), + Step::ColumnMode => vec![ + CmdOption::new("None", "NONE"), + CmdOption::new("Static", "STATIC"), + CmdOption::new("Dynamic", "DYNAMIC"), + ], + _ => Vec::new(), + } + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::FirstCorner | Step::OppositeCorner) + } + + fn wants_text_input(&self) -> bool { + true + } + + fn wants_text_with_spaces(&self) -> bool { + matches!(self.step, Step::Style) + } + + fn dyn_field(&self) -> DynField { + match self.step { + Step::Rotation => DynField::Angle, + Step::Height + | Step::LineSpacing + | Step::Width + | Step::ColumnCount + | Step::ColumnWidth + | Step::ColumnGutter => DynField::Scalar, + _ => DynField::Point, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let trimmed = text.trim(); + let upper = trimmed.to_ascii_uppercase(); + if matches!(self.step, Step::FirstCorner | Step::OppositeCorner) + && self.begin_option(&upper) + { + return Some(CmdResult::NeedPoint); + } + + match self.step { + Step::Height => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value <= 0.0 { + return None; + } + self.height = value; + self.resume_point_step(); + } + Step::Justify => { + self.attachment = match upper.as_str() { + "TL" => AttachmentPoint::TopLeft, + "TC" => AttachmentPoint::TopCenter, + "TR" => AttachmentPoint::TopRight, + "ML" => AttachmentPoint::MiddleLeft, + "MC" => AttachmentPoint::MiddleCenter, + "MR" => AttachmentPoint::MiddleRight, + "BL" => AttachmentPoint::BottomLeft, + "BC" => AttachmentPoint::BottomCenter, + "BR" => AttachmentPoint::BottomRight, + _ => return None, + }; + self.resume_point_step(); + } + Step::LineSpacing => { + let value = trimmed.replace(',', ".").parse::().ok()?; + if !(0.25..=4.0).contains(&value) { + return None; + } + self.line_spacing = value; + self.resume_point_step(); + } + Step::Rotation => { + self.rotation = crate::entities::common::parse_typed_direction(trimmed)?; + self.resume_point_step(); + } + Step::Style => { + if trimmed.is_empty() { + return None; + } + self.style = trimmed.to_string(); + self.resume_point_step(); + } + Step::Width => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value < 0.0 { + return None; + } + self.width = Some(value); + self.resume_point_step(); + } + Step::ColumnMode => { + self.column_type = match upper.as_str() { + "N" | "NONE" => 0, + "S" | "STATIC" => 1, + "D" | "DYNAMIC" => 2, + _ => return None, + }; + if self.column_type == 0 { + self.resume_point_step(); + } else { + self.step = Step::ColumnCount; + } + } + Step::ColumnCount => { + let value = trimmed.parse::().ok()?; + if value < 1 { + return None; + } + self.column_count = value; + self.step = Step::ColumnWidth; + } + Step::ColumnWidth => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value <= 0.0 { + return None; + } + self.column_width = value; + self.step = Step::ColumnGutter; + } + Step::ColumnGutter => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value < 0.0 { + return None; + } + self.column_gutter = value; + self.resume_point_step(); + } + Step::FirstCorner | Step::OppositeCorner => return None, + } + Some(CmdResult::NeedPoint) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::FirstCorner => { + self.first = Some(point); + self.step = Step::OppositeCorner; + CmdResult::NeedPoint + } + Step::OppositeCorner => self.open_editor(point), + _ => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + Step::Height + | Step::Justify + | Step::LineSpacing + | Step::Rotation + | Step::Style + | Step::Width + | Step::ColumnMode + | Step::ColumnCount + | Step::ColumnWidth + | Step::ColumnGutter => { + self.resume_point_step(); + CmdResult::NeedPoint + } + _ => CmdResult::Cancel, + } } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } - fn on_mouse_move(&mut self, _pt: DVec3) -> Option { + fn on_mouse_move(&mut self, _point: DVec3) -> Option { None } } - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] }); // MTextCommand +inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] }); From 96ca741f3587339d1880ef2699ce36e2aea871ec Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:22 +0300 Subject: [PATCH 08/20] Align MText properties and column layout --- src/entities/attribute.rs | 3 + src/entities/mtext.rs | 140 +++++++++++++++++++++++++++------- src/entities/multileader.rs | 16 ++++ src/entities/table.rs | 6 ++ src/entities/text_support.rs | 79 +++++++++++++++++-- src/scene/cache/properties.rs | 4 +- 6 files changed, 214 insertions(+), 34 deletions(-) diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index dd5cc539..47991b80 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -200,6 +200,7 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument oblique_angle, is_backward: width_factor < 0.0, is_upside_down: false, + is_vertical: false, }; let layout = layout_mtext(&MTextRenderOpts { // Not an MTEXT: text in a fixed box, never columnar. @@ -213,6 +214,8 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument attach_h_anchor, v_anchor, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index 5c6a46eb..3dcee115 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -28,6 +28,9 @@ fn columns_of(t: &MText) -> MTextColumns { count: c.column_count.max(0) as usize, width: c.width as f32, gutter: c.gutter as f32, + flow_reversed: c.flow_reversed, + auto_height: c.auto_height, + heights: c.heights.iter().map(|height| *height as f32).collect(), } } @@ -120,7 +123,14 @@ pub fn glyph_boxes(t: &MText, document: &acadrust::CadDocument) -> Vec attach_h_anchor, v_anchor, line_spacing_factor: t.line_spacing_factor as f32, - vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom), + exact_line_spacing: matches!( + t.line_spacing_style, + acadrust::entities::LineSpacingStyle::Exactly + ), + rectangle_height: t.rectangle_height.unwrap_or(0.0) as f32, + vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom) + || matches!(t.drawing_direction, DrawingDirection::ByStyle) + && resolved_style.is_vertical, want_glyph_boxes: true, columns: columns_of(t), }); @@ -169,7 +179,14 @@ fn to_render(t: &MText, document: &acadrust::CadDocument) -> RenderEntity { attach_h_anchor, v_anchor, line_spacing_factor: t.line_spacing_factor as f32, - vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom), + exact_line_spacing: matches!( + t.line_spacing_style, + acadrust::entities::LineSpacingStyle::Exactly + ), + rectangle_height: t.rectangle_height.unwrap_or(0.0) as f32, + vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom) + || matches!(t.drawing_direction, DrawingDirection::ByStyle) + && resolved_style.is_vertical, want_glyph_boxes: false, columns: columns_of(t), }); @@ -231,8 +248,43 @@ fn grips(t: &MText) -> Vec { t.insertion_point.z, ); let (dir, k) = width_grip_axis(t); - let width_grip = p + dir * (k * t.rectangle_width.max(0.0)); - vec![square_grip(0, p), triangle_grip(1, width_grip)] + let columns_active = t.column_data.column_type != 0 + && t.column_data.column_count > 1 + && t.column_data.width > 0.0; + let block_width = if columns_active { + t.column_data.width * t.column_data.column_count as f64 + + t.column_data.gutter * (t.column_data.column_count - 1) as f64 + } else { + t.rectangle_width.max(0.0) + }; + let width_grip = p + dir * (k * block_width); + let mut result = vec![square_grip(0, p), triangle_grip(1, width_grip)]; + + let height = t + .rectangle_height + .or_else(|| t.column_data.heights.first().copied()) + .unwrap_or(0.0); + if height > 0.0 && !(columns_active && t.column_data.auto_height) { + let (sin, cos) = t.rotation.sin_cos(); + let down = glam::DVec3::new(sin, -cos, 0.0); + let (_, vertical) = attach_anchors(t); + let factor = match vertical { + MTextVAnchor::Top + | MTextVAnchor::MiddleOfTopLine + | MTextVAnchor::BottomOfTopLine => 1.0, + MTextVAnchor::Middle => 0.5, + MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => -1.0, + }; + result.push(triangle_grip(2, p + down * (factor * height))); + } + if columns_active { + result.push(triangle_grip(3, p + dir * (k * t.column_data.width))); + result.push(triangle_grip( + 4, + p + dir * (k * (t.column_data.width + t.column_data.gutter)), + )); + } + result } fn columns_str(c: &acadrust::entities::MTextColumnData) -> &'static str { @@ -371,20 +423,6 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec { .collect(), }, }, - // Count / width / gutter are live only when columns are on. - num_row( - t!("Column count").as_ref(), - "col_count", - t.column_data.column_count as f64, - col_type != 0, - ), - num_row(t!("Column width").as_ref(), "col_width", t.column_data.width, col_type != 0), - num_row( - t!("Column gutter").as_ref(), - "col_gutter", - t.column_data.gutter, - col_type != 0, - ), Property { label: t!("Text frame").into_owned(), field: "text_frame", @@ -502,21 +540,21 @@ fn apply_geom_prop(t: &mut MText, field: &str, value: &str) { "ins_y" => t.insertion_point.y = v, "ins_z" => t.insertion_point.z = v, "height" if v > 0.0 => t.height = v, - "rect_w" if v > 0.0 => t.rectangle_width = v, - "rect_h" if v > 0.0 => t.rectangle_height = Some(v), + "rect_w" if v >= 0.0 => t.rectangle_width = v, + "rect_h" if v >= 0.0 => t.rectangle_height = (v > 0.0).then_some(v), "rotation" => t.rotation = v.to_radians(), - "line_spacing" if v > 0.0 => t.line_spacing_factor = v, + "line_spacing" if (0.25..=4.0).contains(&v) => t.line_spacing_factor = v, // Editing the absolute distance back-solves the line-spacing factor so // the two stay consistent (distance = height × 5/3 × factor). "line_space_distance" if v > 0.0 => { let denom = t.height * 1.666_666_666_666_667; if denom > 0.0 { - t.line_spacing_factor = v / denom; + let factor = v / denom; + if (0.25..=4.0).contains(&factor) { + t.line_spacing_factor = factor; + } } } - "col_count" if v >= 1.0 => t.column_data.column_count = v.round() as i32, - "col_width" if v > 0.0 => t.column_data.width = v, - "col_gutter" if v >= 0.0 => t.column_data.gutter = v, _ => {} } } @@ -541,7 +579,57 @@ fn apply_grip(t: &mut MText, grip_id: usize, apply: GripApply) { let dx = p.x as f64 - t.insertion_point.x; let dy = p.y as f64 - t.insertion_point.y; let projected = dx * dir.x + dy * dir.y; - t.rectangle_width = (projected / k).max(0.01); + let width = (projected / k).max(0.0); + if t.column_data.column_type != 0 && t.column_data.column_count > 1 { + let gaps = (t.column_data.column_count - 1) as f64; + t.column_data.width = + ((width - t.column_data.gutter * gaps) / t.column_data.column_count as f64) + .max(0.01); + } else { + t.rectangle_width = width; + } + } + (2, GripApply::Absolute(p)) => { + let (sin, cos) = t.rotation.sin_cos(); + let down = glam::DVec3::new(sin, -cos, 0.0); + let delta = glam::DVec3::new( + p.x as f64 - t.insertion_point.x, + p.y as f64 - t.insertion_point.y, + p.z as f64 - t.insertion_point.z, + ); + let (_, vertical) = attach_anchors(t); + let factor = match vertical { + MTextVAnchor::Top + | MTextVAnchor::MiddleOfTopLine + | MTextVAnchor::BottomOfTopLine => 1.0, + MTextVAnchor::Middle => 0.5, + MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => -1.0, + }; + let height = (delta.dot(down) / factor).max(0.01); + t.rectangle_height = Some(height); + if t.column_data.column_type != 0 && !t.column_data.auto_height { + t.column_data.heights = + vec![height; t.column_data.column_count.max(1) as usize]; + } + } + (3, GripApply::Absolute(p)) => { + let (dir, k) = width_grip_axis(t); + let delta = glam::DVec3::new( + p.x as f64 - t.insertion_point.x, + p.y as f64 - t.insertion_point.y, + 0.0, + ); + t.column_data.width = (delta.dot(dir) / k).max(0.01); + } + (4, GripApply::Absolute(p)) => { + let (dir, k) = width_grip_axis(t); + let delta = glam::DVec3::new( + p.x as f64 - t.insertion_point.x, + p.y as f64 - t.insertion_point.y, + 0.0, + ); + t.column_data.gutter = + (delta.dot(dir) / k - t.column_data.width).max(0.0); } _ => {} } diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index ed985a38..4084a780 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -195,6 +195,11 @@ fn to_render(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option ([f64; 2], [f64; 3]) { oblique_angle: 0.0, is_backward: false, is_upside_down: false, + is_vertical: false, }; let layout = layout_mtext(&MTextRenderOpts { columns: Default::default(), @@ -424,6 +430,11 @@ fn text_box_geom(ml: &MultiLeader) -> ([f64; 2], [f64; 3]) { attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: ctx.line_spacing_factor as f32, + exact_line_spacing: matches!( + ctx.line_spacing_style, + acadrust::entities::LineSpacingStyle::Exactly + ), + rectangle_height: 0.0, vertical_text: vertical, want_glyph_boxes: false, }); @@ -2039,6 +2050,11 @@ impl MultiLeaderTess for MultiLeader { attach_h_anchor: h_anchor, v_anchor, line_spacing_factor: ctx.line_spacing_factor as f32, + exact_line_spacing: matches!( + ctx.line_spacing_style, + acadrust::entities::LineSpacingStyle::Exactly + ), + rectangle_height: 0.0, // Vertical flow (top-to-bottom) is stored per-context. vertical_text: matches!( ctx.text_flow_direction, diff --git a/src/entities/table.rs b/src/entities/table.rs index 6660c8c6..0a75045d 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -1187,6 +1187,7 @@ impl RenderConvertible for Table { oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0), is_backward: style.map(|s| s.is_backward()).unwrap_or(false), is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false), + is_vertical: style.map(|s| s.is_vertical).unwrap_or(false), } }; @@ -1287,6 +1288,8 @@ impl RenderConvertible for Table { attach_h_anchor, v_anchor, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); @@ -1453,6 +1456,7 @@ pub fn tessellate_table( oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0), is_backward: style.map(|s| s.is_backward()).unwrap_or(false), is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false), + is_vertical: style.map(|s| s.is_vertical).unwrap_or(false), } }; @@ -1920,6 +1924,8 @@ pub fn tessellate_table( attach_h_anchor, v_anchor, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 0ddc32aa..0ccd9892 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -11,6 +11,7 @@ pub struct ResolvedTextStyle { pub oblique_angle: f32, pub is_backward: bool, pub is_upside_down: bool, + pub is_vertical: bool, } pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedTextStyle { @@ -76,6 +77,7 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0), is_backward: style.map(|s| s.is_backward()).unwrap_or(false), is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false), + is_vertical: style.map(|s| s.is_vertical).unwrap_or(false), } } @@ -1012,6 +1014,11 @@ pub struct MTextRenderOpts<'a> { pub v_anchor: MTextVAnchor, /// DXF code 44 — multiplier on the default 5/3-em baseline gap. pub line_spacing_factor: f32, + /// `true` fixes every baseline advance to the entity spacing. `false` + /// treats it as a minimum and expands for taller inline runs. + pub exact_line_spacing: bool, + /// User-defined text-box/column height. Zero means content-driven. + pub rectangle_height: f32, /// `true` when the entity is laid out top-to-bottom (DXF code 71 = 2). pub vertical_text: bool, /// When true, `layout_mtext` also fills `MTextLayout::glyph_boxes` with @@ -1025,10 +1032,9 @@ pub struct MTextRenderOpts<'a> { /// An MTEXT's column layout, flattened from its `column_data`. /// -/// Content moves to the next column at a `\N` break. Filling a column and -/// spilling into the next on its own is not modelled: `heights` / `auto_height` -/// are not read, so a column runs as long as its content does. -#[derive(Clone, Copy, Debug, Default)] +/// Content moves to the next column at an explicit `\N` break or when the +/// configured manual/automatic column height is exhausted. +#[derive(Clone, Debug, Default)] pub struct MTextColumns { /// Column count. 0 or 1 both mean "no column layout". pub count: usize, @@ -1036,6 +1042,12 @@ pub struct MTextColumns { pub width: f32, /// Gap between two adjacent columns, world units. pub gutter: f32, + /// Whether logical column flow starts at the opposite side. + pub flow_reversed: bool, + /// Dynamic-column height is balanced from content when true. + pub auto_height: bool, + /// Optional manual height for each logical column. + pub heights: Vec, } impl MTextColumns { @@ -1046,7 +1058,12 @@ impl MTextColumns { /// Left edge of column `i`, relative to the text block's own left edge. pub fn offset_of(&self, i: usize) -> f32 { - i as f32 * (self.width + self.gutter) + let physical = if self.flow_reversed { + self.count.saturating_sub(1).saturating_sub(i) + } else { + i + }; + physical as f32 * (self.width + self.gutter) } } @@ -1128,7 +1145,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { line_spacing: Option, } - let cols = opts.columns; + let cols = opts.columns.clone(); // A `\N` past the last column has nowhere to go — keep those lines in the // last one rather than laying them out beyond the block. let last_col = cols.count.saturating_sub(1); @@ -1366,15 +1383,55 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { }; // A paragraph's own `\psm#`/`\pse#` overrides the entity factor for // its lines; `Exact` fixes the baseline gap outright. + let entity_gap = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing(); match s.line_spacing { Some(ParaLineSpacing::Exact(e)) if e > 0.0 => e, Some(ParaLineSpacing::Multiple(m)) if m > 0.0 => { mh * m * (5.0 / 3.0) * base_font.line_spacing() } - _ => mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing(), + _ if opts.exact_line_spacing => entity_gap, + _ => (mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing()) + .max(entity_gap), } }) .collect(); + + // Flow lines into the next column when the active column's configured + // height is exhausted. Explicit `\N` breaks remain authoritative. Dynamic + // auto-height balances the content; static/manual columns use their stored + // height (falling back to the entity rectangle height). + if cols.active() { + let total_advance: f32 = per_line_h.iter().sum(); + let balanced = (total_advance / cols.count.max(1) as f32).max(entity_h); + let mut current_column = 0usize; + let mut used = 0.0_f32; + for (index, line) in sub_lines.iter_mut().enumerate() { + if line.column > current_column { + current_column = line.column.min(cols.count - 1); + used = 0.0; + } + let limit = if cols.auto_height { + balanced + } else { + cols.heights + .get(current_column) + .copied() + .filter(|height| *height > 0.0) + .unwrap_or(opts.rectangle_height) + }; + let advance = per_line_h.get(index).copied().unwrap_or(entity_h); + if limit > 0.0 + && used > 0.0 + && used + advance > limit + && current_column + 1 < cols.count + { + current_column += 1; + used = 0.0; + } + line.column = current_column; + used += advance; + } + } // Per-line text height — the tallest Word on the line, an empty line // inheriting the previous line's height (same rule as `per_line_h`). Unlike // `line_max_h` it has no `entity_h` floor, so it reflects the ACTUAL text @@ -2169,6 +2226,7 @@ mod tests { oblique_angle: 0.0, is_backward: false, is_upside_down: false, + is_vertical: false, } } @@ -2199,6 +2257,8 @@ mod tests { attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); @@ -2222,6 +2282,8 @@ mod tests { attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); @@ -2378,6 +2440,7 @@ mod v_anchor_tests { oblique_angle: 0.0, is_backward: false, is_upside_down: false, + is_vertical: false, }; layout_mtext(&MTextRenderOpts { columns: Default::default(), @@ -2390,6 +2453,8 @@ mod v_anchor_tests { attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: true, }) diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index fe199e88..d60bbfd6 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -94,7 +94,9 @@ pub fn general_section(entity: &EntityType) -> PropSection { ], }; - if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) { + if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) + || matches!(entity, EntityType::MText(_)) + { section.props.retain(|prop| prop.field != "handle"); } From af4401ce675480239d3bdb353492c01772f35058 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:23 +0300 Subject: [PATCH 09/20] Complete MText editor controls and persistence --- src/app/mod.rs | 25 ++ src/app/mtext_editor.rs | 500 ++++++++++++++++++++++++++++++++++++++-- src/app/update/mod.rs | 121 +++++++++- src/app/view/overlay.rs | 165 ++++++++++++- 4 files changed, 775 insertions(+), 36 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 8324cf55..7223af75 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2655,6 +2655,31 @@ pub enum Message { MTextWidth(String), /// Toolbar character-spacing field changed. MTextCharSpace(String), + /// Undo / redo editor text and inline-format operations. + MTextUndo, + MTextRedo, + /// Convert the selected numerator/separator/denominator to a stacked run. + MTextStack, + /// Remove inline character formatting from the selection (or all text). + MTextClearFormatting, + /// Insert a predefined symbol or field token at the caret. + MTextInsert(String), + /// Per-object annotation flag edited from the text toolbar. + MTextAnnotative(bool), + /// Column layout controls. + MTextColumnMode(String), + MTextColumnCount(String), + MTextColumnWidth(String), + MTextColumnGutter(String), + MTextColumnHeight(String), + MTextColumnFlowReversed(bool), + /// Paragraph indent/spacing controls. + MTextParagraphNumber(mtext_editor::ParaNumber, String), + MTextFindText(String), + MTextReplaceText(String), + MTextFindNext, + MTextReplaceNext, + MTextReplaceAll, /// Toolbar colour picker (same widget as Properties) — applies to the /// selection, or the whole text when nothing is selected. MTextColorChanged(AcadColor), diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index f5dae78e..5d6c851c 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -8,7 +8,7 @@ use acadrust::entities::mtext::AttachmentPoint; use acadrust::entities::mtext_format::{ parse_mtext, MTextColor, MTextDocument, MTextFont, MTextParagraph, MTextParagraphAlignment, - MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType, + MTextScalar, MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType, }; use acadrust::types::Vector3; use acadrust::{EntityType, Handle, MText}; @@ -38,6 +38,15 @@ pub enum ParaAlign { Justify, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParaNumber { + FirstIndent, + LeftIndent, + RightIndent, + SpaceBefore, + SpaceAfter, +} + /// `pick_list`-friendly wrapper for the 9 attachment points. #[derive(Clone, Copy, PartialEq, Eq)] pub struct JustifyChoice(pub AttachmentPoint); @@ -107,6 +116,23 @@ pub struct MTextEditorState { /// it is NOT derived from the typed content, so adding characters wraps /// to the next line instead of stretching the box into one long line. pub rect_width: f64, + pub rect_height: String, + pub annotative: bool, + pub column_type: i16, + pub column_count: String, + pub column_auto_height: bool, + pub column_flow_reversed: bool, + pub column_width: String, + pub column_gutter: String, + pub paragraph_first_indent: String, + pub paragraph_left_indent: String, + pub paragraph_right_indent: String, + pub paragraph_space_before: String, + pub paragraph_space_after: String, + pub find_text: String, + pub replace_text: String, + undo: Vec, + redo: Vec, /// `Some` when editing an existing entity; `None` for a fresh MText. pub editing: Option, /// When true the panel shows the rendered preview; when false the raw @@ -167,6 +193,23 @@ impl MTextEditorState { // Default box ~20 characters wide; overwritten with the entity's // own width when editing an existing MText. rect_width: (height * 20.0).max(1.0), + rect_height: "0".to_string(), + annotative: false, + column_type: 0, + column_count: "2".to_string(), + column_auto_height: false, + column_flow_reversed: false, + column_width: (height * 10.0).to_string(), + column_gutter: (height * 5.0).to_string(), + paragraph_first_indent: "0".to_string(), + paragraph_left_indent: "0".to_string(), + paragraph_right_indent: "0".to_string(), + paragraph_space_before: "0".to_string(), + paragraph_space_after: "0".to_string(), + find_text: String::new(), + replace_text: String::new(), + undo: Vec::new(), + redo: Vec::new(), editing, screen_anchor: iced::Point::new(60.0, 90.0), original: None, @@ -274,8 +317,53 @@ impl MTextEditorState { mt.attachment_point = self.attachment; mt.line_spacing_factor = self.line_spacing as f64; mt.style = self.style.clone(); + mt.is_annotative = self.annotative; + let rectangle_height = self.rect_height.trim().parse::().unwrap_or(0.0); + mt.rectangle_height = (rectangle_height > 0.0).then_some(rectangle_height); + mt.column_data.column_type = self.column_type; + if self.column_type == 0 { + mt.column_data.column_count = 0; + mt.column_data.heights.clear(); + } else { + mt.column_data.column_count = self + .column_count + .trim() + .parse::() + .ok() + .filter(|count| *count > 0) + .unwrap_or(1); + mt.column_data.auto_height = self.column_auto_height; + mt.column_data.flow_reversed = self.column_flow_reversed; + if let Ok(width) = self.column_width.trim().parse::() { + if width > 0.0 { + mt.column_data.width = width; + } + } + if let Ok(gutter) = self.column_gutter.trim().parse::() { + if gutter >= 0.0 { + mt.column_data.gutter = gutter; + } + } + if !self.column_auto_height && rectangle_height > 0.0 { + mt.column_data.heights = vec![ + rectangle_height; + mt.column_data.column_count.max(1) as usize + ]; + } + } mt } + + fn record_undo(&mut self) { + let value = self.content.text(); + if self.undo.last().is_none_or(|last| last != &value) { + self.undo.push(value); + if self.undo.len() > 100 { + self.undo.remove(0); + } + } + self.redo.clear(); + } } /// Parse a numeric field, returning `Some(v)` only when it differs from the @@ -595,6 +683,24 @@ fn vertical_caret_target( best.1 } +fn seed_mtext_state(state: &mut MTextEditorState, m: &MText) { + state.attachment = m.attachment_point; + state.line_spacing = m.line_spacing_factor as f32; + state.rect_width = m.rectangle_width.max(0.0); + state.rect_height = m.rectangle_height.unwrap_or(0.0).max(0.0).to_string(); + state.annotative = m.is_annotative; + state.column_type = m.column_data.column_type; + state.column_count = m.column_data.column_count.max(1).to_string(); + state.column_auto_height = m.column_data.auto_height; + state.column_flow_reversed = m.column_data.flow_reversed; + state.column_width = m.column_data.width.max(0.0).to_string(); + state.column_gutter = m.column_data.gutter.max(0.0).to_string(); + if !m.style.trim().is_empty() { + state.style = m.style.clone(); + } + state.original = Some(m.clone()); +} + impl super::OpenCADStudio { /// Open the in-place editor for a new (`handle = None`) or existing MText. /// Open the rich MText editor for a new or existing MText / MultiLeader. @@ -605,6 +711,7 @@ impl super::OpenCADStudio { handle: Option, initial: &str, height: f64, + template: Option, ) { if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) { return; @@ -614,20 +721,11 @@ impl super::OpenCADStudio { state.screen_anchor = p; } // Seed attachment / line-spacing / box width from the entity being edited. + let has_template = template.is_some(); if let Some(h) = handle { match self.tabs[self.active_tab].scene.document.get_entity(h) { Some(EntityType::MText(m)) => { - state.attachment = m.attachment_point; - state.line_spacing = m.line_spacing_factor as f32; - if !m.style.trim().is_empty() { - state.style = m.style.clone(); - } - if m.rectangle_width > 0.0 { - state.rect_width = m.rectangle_width; - } - // Keep the whole entity so `build_mtext` preserves the fields - // the toolbar can't edit (columns, rotation, background…). - state.original = Some(m.clone()); + seed_mtext_state(&mut state, m); } Some(EntityType::MultiLeader(ml)) => { state.line_spacing = ml.context.line_spacing_factor as f32; @@ -637,6 +735,8 @@ impl super::OpenCADStudio { } _ => {} } + } else if let Some(m) = template { + seed_mtext_state(&mut state, &m); } else { // New MText inherits the document's current text style (STYLE), // not the "Standard" default. See #92. @@ -659,7 +759,7 @@ impl super::OpenCADStudio { // The preview uses a fixed on-screen text size. Therefore the initial // wrap width is the drawing-unit span that exactly reaches the right // edge of the editor, placing both ruler and slider at their maximum. - if handle.is_none() { + if handle.is_none() && !has_template { let initial_width = self.mtext_editor.as_ref().map(|ed| { super::view::overlay::MTEXT_EDITOR_WRITING_WIDTH / ed.preview_scale() }); @@ -746,6 +846,318 @@ impl super::OpenCADStudio { /// Splice text around the preview selection (visible-char range) in the /// raw value. `case` optionally transforms the selected slice. Returns /// true when a preview selection was present and applied. + pub(super) fn mtext_undo(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + if let Some(previous) = ed.undo.pop() { + ed.redo.push(ed.content.text()); + ed.content = text_editor::Content::with_text(&previous); + ed.caret = previous.chars().count(); + ed.sel = Some((ed.caret, ed.caret)); + ed.sel_anchor = ed.caret; + } + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_redo(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + if let Some(next) = ed.redo.pop() { + ed.undo.push(ed.content.text()); + ed.content = text_editor::Content::with_text(&next); + ed.caret = next.chars().count(); + ed.sel = Some((ed.caret, ed.caret)); + ed.sel_anchor = ed.caret; + } + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_apply_span_number(&mut self, field: &'static str, value: String) { + let Some(number) = value.trim().replace(',', ".").parse::().ok() else { + return; + }; + let valid = match field { + "height" => number > 0.0, + "oblique" => (-85.0..=85.0).contains(&number), + "width" => number > 0.0, + "tracking" => number.is_finite(), + _ => false, + }; + if !valid { + return; + } + let has_selection = self + .mtext_editor + .as_ref() + .and_then(|editor| editor.sel) + .is_some_and(|(start, end)| start < end); + if let Some(ed) = self.mtext_editor.as_mut() { + if has_selection { + let (start, end) = ed.sel.unwrap(); + ed.record_undo(); + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let end = end.min(cells.len()); + for cell in &mut cells[start.min(end)..end] { + if let Cell::Char(_, props) | Cell::Stack { props, .. } = cell { + match field { + "height" => props.height = Some(MTextScalar::Absolute(number)), + "oblique" => props.oblique_angle = (number != 0.0).then_some(number), + "width" => props.width_factor = (number != 1.0).then_some(number), + "tracking" => props.tracking = (number != 0.0).then_some(number), + _ => {} + } + } + } + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.sel = Some((start, end)); + ed.caret = end; + } else { + match field { + "height" => ed.height = value, + "oblique" => ed.oblique = value, + "width" => ed.width = value, + "tracking" => ed.char_space = value, + _ => {} + } + } + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_apply_paragraph_number(&mut self, field: ParaNumber, value: String) { + let Some(number) = value.trim().replace(',', ".").parse::().ok() else { + return; + }; + if !number.is_finite() { + return; + } + if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); + match field { + ParaNumber::FirstIndent => ed.paragraph_first_indent = value, + ParaNumber::LeftIndent => ed.paragraph_left_indent = value, + ParaNumber::RightIndent => ed.paragraph_right_indent = value, + ParaNumber::SpaceBefore => ed.paragraph_space_before = value, + ParaNumber::SpaceAfter => ed.paragraph_space_after = value, + } + let para0 = doc_para0(&ed.doc); + let cells = doc_to_cells(&ed.doc); + let (start, end) = ed + .sel + .filter(|(start, end)| start < end) + .unwrap_or((ed.caret, ed.caret)); + let paragraph_of = |index: usize| { + cells + .iter() + .take(index.min(cells.len())) + .filter(|cell| matches!(cell, Cell::Break(..))) + .count() + }; + let first = paragraph_of(start); + let last = paragraph_of(end.saturating_sub((end > start) as usize)); + let mut doc = cells_to_doc(¶0, &cells); + for paragraph in doc + .paragraphs + .iter_mut() + .skip(first) + .take(last.saturating_sub(first) + 1) + { + let optional = (number != 0.0).then_some(number); + match field { + ParaNumber::FirstIndent => paragraph.properties.first_line_indent = optional, + ParaNumber::LeftIndent => paragraph.properties.left_margin = optional, + ParaNumber::RightIndent => paragraph.properties.right_margin = optional, + ParaNumber::SpaceBefore => paragraph.properties.spacing_before = optional, + ParaNumber::SpaceAfter => paragraph.properties.spacing_after = optional, + } + } + ed.content = text_editor::Content::with_text(&doc.to_mtext_string()); + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_stack_selection(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let Some((start, end)) = ed.sel.filter(|(start, end)| start < end) else { + return; + }; + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let end = end.min(cells.len()); + let plain = cells_to_doc(&ParagraphProperties::default(), &cells[start..end]) + .to_plain_text(); + let Some((split, separator)) = plain + .char_indices() + .find(|(_, ch)| matches!(ch, '/' | '#' | '^')) + else { + return; + }; + let separator_len = separator.len_utf8(); + let data = StackingData { + numerator: plain[..split].to_string(), + denominator: plain[split + separator_len..].to_string(), + stacking_type: StackingType::from_char(separator), + }; + if data.numerator.is_empty() || data.denominator.is_empty() { + return; + } + let props = cells[start..end] + .iter() + .find_map(|cell| match cell { + Cell::Char(_, props) | Cell::Stack { props, .. } => Some(props.clone()), + Cell::Break(..) => None, + }) + .unwrap_or_default(); + ed.record_undo(); + let replacement = flatten_stack(&data) + .chars() + .enumerate() + .map(|(index, _)| Cell::Stack { + data: data.clone(), + props: props.clone(), + head: index == 0, + }) + .collect::>(); + let replacement_len = replacement.len(); + cells.splice(start..end, replacement); + let new_end = start + replacement_len; + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.sel = Some((start, new_end)); + ed.caret = new_end; + ed.sel_anchor = start; + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_clear_formatting(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let (start, end) = ed + .sel + .filter(|(start, end)| start < end) + .unwrap_or((0, cells.len())); + let end = end.min(cells.len()); + ed.record_undo(); + for cell in &mut cells[start.min(end)..end] { + if let Cell::Char(_, props) | Cell::Stack { props, .. } = cell { + *props = SpanProperties::default(); + } + } + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.sel = Some((start, end)); + ed.caret = end; + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_find_next(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let needle: Vec = ed.find_text.chars().collect(); + if needle.is_empty() { + return; + } + let cells = doc_to_cells(&ed.doc); + let chars: Vec> = cells + .iter() + .map(|cell| match cell { + Cell::Char(ch, _) => Some(*ch), + Cell::Break(..) => Some('\n'), + Cell::Stack { .. } => None, + }) + .collect(); + let start = ed.caret.min(chars.len()); + let found = (start..=chars.len().saturating_sub(needle.len())) + .chain(0..start.min(chars.len().saturating_sub(needle.len()) + 1)) + .find(|index| { + chars[*index..*index + needle.len()] + .iter() + .zip(&needle) + .all(|(actual, expected)| actual == &Some(*expected)) + }); + if let Some(found) = found { + ed.sel_anchor = found; + ed.sel = Some((found, found + needle.len())); + ed.caret = found + needle.len(); + ed.caret_blink_on = true; + } + } + } + + pub(super) fn mtext_replace_next(&mut self) { + let replacement = self + .mtext_editor + .as_ref() + .map(|editor| editor.replace_text.clone()) + .unwrap_or_default(); + let matches = self.mtext_editor.as_ref().is_some_and(|editor| { + let Some((start, end)) = editor.sel.filter(|(start, end)| start < end) else { + return false; + }; + let cells = doc_to_cells(&editor.doc); + cells_to_doc( + &ParagraphProperties::default(), + &cells[start.min(cells.len())..end.min(cells.len())], + ) + .to_plain_text() + == editor.find_text + }); + if matches { + self.mtext_type(&replacement); + } + self.mtext_find_next(); + } + + pub(super) fn mtext_replace_all(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let needle: Vec = ed.find_text.chars().collect(); + if needle.is_empty() { + return; + } + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let replacement = ed.replace_text.clone(); + let mut matches = Vec::new(); + let mut index = 0usize; + while index + needle.len() <= cells.len() { + let found = cells[index..index + needle.len()] + .iter() + .zip(&needle) + .all(|(cell, expected)| matches!(cell, Cell::Char(actual, _) if actual == expected)); + if found { + matches.push(index); + index += needle.len(); + } else { + index += 1; + } + } + if matches.is_empty() { + return; + } + ed.record_undo(); + for start in matches.into_iter().rev() { + let props = insert_props(&cells, start); + let paragraph = para_props_at(¶0, &cells, start); + let replacement_cells = str_to_cells(&replacement, &props, ¶graph); + cells.splice(start..start + needle.len(), replacement_cells); + } + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.caret = cells.len(); + ed.sel = Some((ed.caret, ed.caret)); + ed.sel_anchor = ed.caret; + } + self.rebuild_mtext_preview(); + } + /// Toggle a character format over the preview selection, on the structured /// span properties. The stroke-font renderer has no true bold, so Bold /// switches the run to the heavier "Gothic" face; Italic applies a 15° @@ -798,6 +1210,7 @@ impl super::OpenCADStudio { if a >= b { return; } + ed.record_undo(); match kind { MTextFmt::Underline => { let on = !all_have(&cells[a..b], |p| p.underline()); @@ -882,6 +1295,7 @@ impl super::OpenCADStudio { /// Set the alignment of every paragraph the selection (or caret) touches. pub(super) fn mtext_apply_align(&mut self, align: ParaAlign) { if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); let para0 = doc_para0(&ed.doc); let cells = doc_to_cells(&ed.doc); let (a, b) = ed @@ -933,6 +1347,7 @@ impl super::OpenCADStudio { s }; if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); // Edit the structured document as a flat cell list, then serialize // it back into the raw content the preview/commit still read. let para0 = doc_para0(&ed.doc); @@ -962,6 +1377,7 @@ impl super::OpenCADStudio { /// Delete the selection, or the visible character before the caret. pub(super) fn mtext_backspace(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let count = cells.len(); @@ -985,6 +1401,7 @@ impl super::OpenCADStudio { /// Delete the selection, or the visible character at the caret. pub(super) fn mtext_delete(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let count = cells.len(); @@ -1104,6 +1521,7 @@ impl super::OpenCADStudio { let mut cells = doc_to_cells(&ed.doc); let b = b.min(cells.len()); if a < b { + ed.record_undo(); for c in &mut cells[a..b] { if let Cell::Char(_, p) | Cell::Stack { props: p, .. } = c { let (bold, italic) = p @@ -1155,6 +1573,7 @@ impl super::OpenCADStudio { let mut cells = doc_to_cells(&ed.doc); let b = b.min(cells.len()); if a < b { + ed.record_undo(); for c in &mut cells[a..b] { if let Cell::Char(_, p) | Cell::Stack { props: p, .. } = c { p.color = mcolor.clone(); @@ -1217,7 +1636,8 @@ impl super::OpenCADStudio { } let body_empty = ed.content.text().trim().is_empty(); let mut mt = ed.build_mtext(); - let annotative = ed.editing.is_none() + let annotative = mt.is_annotative + || ed.editing.is_none() && crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &mt.style, @@ -1240,9 +1660,12 @@ impl super::OpenCADStudio { Some(EntityType::MText(t)) => { t.value = mt.value; t.height = mt.height; + t.style = mt.style; t.attachment_point = mt.attachment_point; t.line_spacing_factor = mt.line_spacing_factor; t.rectangle_width = mt.rectangle_width; + t.rectangle_height = mt.rectangle_height; + t.column_data = mt.column_data; } Some(EntityType::MultiLeader(ml)) => { ml.context.text_string = mt.value; @@ -1255,9 +1678,25 @@ impl super::OpenCADStudio { _ => {} } // Keep the displayed annotation context in sync. + if matches!(self.tabs[i].scene.document.get_entity(h), Some(EntityType::MText(_))) { + crate::scene::annotative::set_entity_annotative( + &mut self.tabs[i].scene.document, + h, + annotative, + ); + if annotative { + if let Some(scale) = self.tabs[i].scene.current_annotation_scale_handle() { + crate::scene::annotative::create_annotation_context( + &mut self.tabs[i].scene.document, + h, + scale, + ); + } + } + } if matches!( self.tabs[i].scene.document.get_entity(h), - Some(EntityType::MultiLeader(_)) + Some(EntityType::MText(_) | EntityType::MultiLeader(_)) ) { self.tabs[i] .scene @@ -1283,7 +1722,6 @@ impl super::OpenCADStudio { position.y, position.z, ); - mt.rotation = 0.0; self.push_undo_snapshot(i, "MTEXT"); let handle = self.commit_entity_handle(plane.place_entity(EntityType::MText(mt))); if annotative { @@ -1321,7 +1759,8 @@ impl super::OpenCADStudio { ), None => return, }; - let annotative = editing.is_none() + let annotative = mt.is_annotative + || editing.is_none() && crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &mt.style, @@ -1341,9 +1780,12 @@ impl super::OpenCADStudio { Some(EntityType::MText(t)) => { t.value = mt.value; t.height = mt.height; + t.style = mt.style; t.attachment_point = mt.attachment_point; t.line_spacing_factor = mt.line_spacing_factor; t.rectangle_width = mt.rectangle_width; + t.rectangle_height = mt.rectangle_height; + t.column_data = mt.column_data; } Some(EntityType::MultiLeader(ml)) => { ml.context.text_string = mt.value; @@ -1355,9 +1797,25 @@ impl super::OpenCADStudio { } _ => {} } + if matches!(self.tabs[i].scene.document.get_entity(h), Some(EntityType::MText(_))) { + crate::scene::annotative::set_entity_annotative( + &mut self.tabs[i].scene.document, + h, + annotative, + ); + if annotative { + if let Some(scale) = self.tabs[i].scene.current_annotation_scale_handle() { + crate::scene::annotative::create_annotation_context( + &mut self.tabs[i].scene.document, + h, + scale, + ); + } + } + } if matches!( self.tabs[i].scene.document.get_entity(h), - Some(EntityType::MultiLeader(_)) + Some(EntityType::MText(_) | EntityType::MultiLeader(_)) ) { self.tabs[i] .scene @@ -1383,7 +1841,6 @@ impl super::OpenCADStudio { position.y, position.z, ); - mt.rotation = 0.0; self.push_undo_snapshot(i, "MTEXT"); let handle = self.commit_entity_handle(plane.place_entity(EntityType::MText(mt))); self.tabs[i].dirty = true; @@ -1400,6 +1857,11 @@ impl super::OpenCADStudio { // Bind the editor to the fresh entity so the next Apply updates it. if let (Some(h), Some(ed)) = (handle, self.mtext_editor.as_mut()) { ed.editing = Some(h); + if let Some(EntityType::MText(mtext)) = + self.tabs[i].scene.document.get_entity(h) + { + ed.original = Some(mtext.clone()); + } } } self.refresh_properties(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a98bb676..4d9b1994 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3880,10 +3880,7 @@ impl OpenCADStudio { Task::none() } Message::MTextHeight(s) => { - if let Some(ed) = self.mtext_editor.as_mut() { - ed.height = s; - } - self.rebuild_mtext_preview(); + self.mtext_apply_span_number("height", s); Task::none() } Message::MTextRectWidth(width) => { @@ -3915,26 +3912,128 @@ impl OpenCADStudio { Task::none() } Message::MTextOblique(s) => { - if let Some(ed) = self.mtext_editor.as_mut() { - ed.oblique = s; - } - self.rebuild_mtext_preview(); + self.mtext_apply_span_number("oblique", s); Task::none() } Message::MTextWidth(s) => { + self.mtext_apply_span_number("width", s); + Task::none() + } + Message::MTextCharSpace(s) => { + self.mtext_apply_span_number("tracking", s); + Task::none() + } + Message::MTextUndo => { + self.mtext_undo(); + Task::none() + } + Message::MTextRedo => { + self.mtext_redo(); + Task::none() + } + Message::MTextStack => { + self.mtext_stack_selection(); + Task::none() + } + Message::MTextClearFormatting => { + self.mtext_clear_formatting(); + Task::none() + } + Message::MTextInsert(value) => { + self.mtext_type(&value); + Task::none() + } + Message::MTextAnnotative(value) => { if let Some(ed) = self.mtext_editor.as_mut() { - ed.width = s; + ed.annotative = value; } self.rebuild_mtext_preview(); Task::none() } - Message::MTextCharSpace(s) => { + Message::MTextColumnMode(mode) => { if let Some(ed) = self.mtext_editor.as_mut() { - ed.char_space = s; + match mode.as_str() { + "Static" => { + ed.column_type = 1; + ed.column_auto_height = false; + } + "Dynamic auto" => { + ed.column_type = 2; + ed.column_auto_height = true; + } + "Dynamic manual" => { + ed.column_type = 2; + ed.column_auto_height = false; + } + _ => ed.column_type = 0, + } } self.rebuild_mtext_preview(); Task::none() } + Message::MTextColumnCount(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.column_count = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColumnWidth(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.column_width = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColumnGutter(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.column_gutter = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColumnHeight(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.rect_height = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColumnFlowReversed(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.column_flow_reversed = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextParagraphNumber(field, value) => { + self.mtext_apply_paragraph_number(field, value); + Task::none() + } + Message::MTextFindText(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.find_text = value; + } + Task::none() + } + Message::MTextReplaceText(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.replace_text = value; + } + Task::none() + } + Message::MTextFindNext => { + self.mtext_find_next(); + Task::none() + } + Message::MTextReplaceNext => { + self.mtext_replace_next(); + Task::none() + } + Message::MTextReplaceAll => { + self.mtext_replace_all(); + Task::none() + } Message::MTextJustify(ap) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.attachment = ap; diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index b5782f81..c1c4dac3 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -6,7 +6,8 @@ use iced::advanced::renderer; use iced::advanced::widget; use iced::advanced::{Layout, Shell, Widget}; use iced::widget::{ - button, column, container, mouse_area, row, scrollable, stack, text, text_input, Space, + button, checkbox, column, container, mouse_area, row, scrollable, stack, text, text_input, + Space, }; use iced::{Background, Border, Color, Element, Event, Fill, Length, Rectangle, Size, Theme, Vector}; use crate::t; @@ -374,7 +375,7 @@ pub(super) fn mtext_editor_overlay<'a>( iced::widget::Space::new().width(Fill).height(Fill), t!("Text Editor"), content, - Message::MTextCancel, + Message::MTextOk, modal_offset, crate::ui::modal::ModalOptions::STANDARD, ) @@ -450,6 +451,9 @@ fn mtext_editor_content<'a>( .on_select(Message::MTextStyle) .text_size(11) .width(iced::Length::Fixed(96.0)); + let annotative = checkbox(ed.annotative) + .on_toggle(Message::MTextAnnotative) + .size(14); let font_sel = if ed.font.trim().is_empty() { "[Style default]".to_string() } else { @@ -487,6 +491,9 @@ fn mtext_editor_content<'a>( let row1 = row![ style_pl, + row![annotative, text(t!("Annotative")).size(11)] + .spacing(3) + .align_y(iced::Alignment::Center), font_pl, small_input("2.5", &ed.height, Message::MTextHeight, 64.0), iced::widget::Space::new().width(6), @@ -535,6 +542,14 @@ fn mtext_editor_content<'a>( .text_size(11) .width(iced::Length::Fixed(112.0)); let row2 = row![ + button(lbl("↶")) + .on_press(Message::MTextUndo) + .padding(3) + .style(btn_style), + button(lbl("↷")) + .on_press(Message::MTextRedo) + .padding(3) + .style(btn_style), lbl("O"), small_input("0", &ed.oblique, Message::MTextOblique, 48.0), lbl("W"), @@ -573,6 +588,144 @@ fn mtext_editor_content<'a>( .on_press(Message::MTextLineSpacing(2.0)) .padding(3) .style(btn_style), + button(lbl("Stack")) + .on_press(Message::MTextStack) + .padding(3) + .style(btn_style), + button(lbl("Clear")) + .on_press(Message::MTextClearFormatting) + .padding(3) + .style(btn_style), + button(lbl("°")) + .on_press(Message::MTextInsert("°".to_string())) + .padding(3) + .style(btn_style), + button(lbl("±")) + .on_press(Message::MTextInsert("±".to_string())) + .padding(3) + .style(btn_style), + button(lbl("⌀")) + .on_press(Message::MTextInsert("⌀".to_string())) + .padding(3) + .style(btn_style), + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .width(width); + + let column_mode = match (ed.column_type, ed.column_auto_height) { + (1, _) => "Static", + (2, true) => "Dynamic auto", + (2, false) => "Dynamic manual", + _ => "No columns", + } + .to_string(); + let column_picker = iced::widget::pick_list( + Some(column_mode), + ["No columns", "Static", "Dynamic auto", "Dynamic manual"] + .into_iter() + .map(str::to_string) + .collect::>(), + |value| value.to_string(), + ) + .on_select(Message::MTextColumnMode) + .text_size(11) + .width(iced::Length::Fixed(120.0)); + let reverse = checkbox(ed.column_flow_reversed) + .on_toggle(Message::MTextColumnFlowReversed) + .size(14); + let row3 = row![ + lbl("Columns"), + column_picker, + lbl("Count"), + small_input("2", &ed.column_count, Message::MTextColumnCount, 42.0), + lbl("Height"), + text_input("0", &ed.rect_height) + .on_input(Message::MTextColumnHeight) + .width(iced::Length::Fixed(58.0)) + .padding(3) + .size(12), + lbl("Width"), + small_input("0", &ed.column_width, Message::MTextColumnWidth, 58.0), + lbl("Gutter"), + small_input("0", &ed.column_gutter, Message::MTextColumnGutter, 58.0), + reverse, + lbl("Reverse"), + iced::widget::Space::new().width(8), + lbl("First"), + text_input("0", &ed.paragraph_first_indent) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::FirstIndent, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("Left"), + text_input("0", &ed.paragraph_left_indent) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::LeftIndent, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("Right"), + text_input("0", &ed.paragraph_right_indent) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::RightIndent, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("Before"), + text_input("0", &ed.paragraph_space_before) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::SpaceBefore, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("After"), + text_input("0", &ed.paragraph_space_after) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::SpaceAfter, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .width(width); + let row4 = row![ + lbl("Find"), + text_input("Find", &ed.find_text) + .on_input(Message::MTextFindText) + .width(iced::Length::Fixed(140.0)) + .padding(3) + .size(12), + button(lbl("Next")) + .on_press(Message::MTextFindNext) + .padding(3) + .style(btn_style), + lbl("Replace"), + text_input("Replace", &ed.replace_text) + .on_input(Message::MTextReplaceText) + .width(iced::Length::Fixed(140.0)) + .padding(3) + .size(12), + button(lbl("Replace")) + .on_press(Message::MTextReplaceNext) + .padding(3) + .style(btn_style), + button(lbl("Replace All")) + .on_press(Message::MTextReplaceAll) + .padding(3) + .style(btn_style), ] .spacing(4) .align_y(iced::Alignment::Center) @@ -710,13 +863,13 @@ fn mtext_editor_content<'a>( .into() }; - // ── Top action bar: Apply on the right, exactly like the style managers' - // toolbar strip. Closing (the modal ✕) without applying discards the - // buffer, so there is no separate Cancel. + // ── Top action bar: Apply keeps editing; Close saves and exits. Escape + // remains the explicit discard path. let action_bar = container( row![ iced::widget::Space::new().width(width), crate::ui::style::style_manager::tb_button(t!("Apply"), Message::MTextApply, true), + crate::ui::style::style_manager::tb_button(t!("Close Text Editor"), Message::MTextOk, true), ] .align_y(iced::Alignment::Center), ) @@ -730,7 +883,7 @@ fn mtext_editor_content<'a>( .padding([5, 8]); container( - column![action_bar, row1, row2, width_slider, body] + column![action_bar, row1, row2, row3, row4, width_slider, body] .spacing(6) .width(width) .height(height), From 8b6724700eca3579b2f563eea2383f014705aca3 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:24 +0300 Subject: [PATCH 10/20] Render MText column masks independently --- src/scene/convert/tessellate.rs | 129 ++++++++++++++++++++++++++------ 1 file changed, 107 insertions(+), 22 deletions(-) diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index d474c5f1..90f7c08d 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -78,6 +78,80 @@ fn oriented_text_corners( ] } +fn oriented_mtext_corner_groups( + verts: &[crate::scene::pipeline::text_gpu::TextVertex], + text: &acadrust::MText, + rotation: f64, + pad: f64, + annotation_scale: f64, +) -> Vec<[[f64; 2]; 4]> { + let columns = &text.column_data; + let count = columns.column_count.max(0) as usize; + if columns.column_type == 0 || count <= 1 || columns.width <= 0.0 { + return vec![oriented_text_corners( + verts, + [text.insertion_point.x, text.insertion_point.y], + rotation, + pad, + )]; + } + + let width = columns.width * annotation_scale; + let gutter = columns.gutter.max(0.0) * annotation_scale; + let total_width = width * count as f64 + gutter * count.saturating_sub(1) as f64; + let anchor = match text.attachment_point { + acadrust::entities::mtext::AttachmentPoint::TopCenter + | acadrust::entities::mtext::AttachmentPoint::MiddleCenter + | acadrust::entities::mtext::AttachmentPoint::BottomCenter => 0.5, + acadrust::entities::mtext::AttachmentPoint::TopRight + | acadrust::entities::mtext::AttachmentPoint::MiddleRight + | acadrust::entities::mtext::AttachmentPoint::BottomRight => 1.0, + _ => 0.0, + }; + let block_left = -anchor * total_width; + let origin = [text.insertion_point.x, text.insertion_point.y]; + let (sin_r, cos_r) = rotation.sin_cos(); + let mut bounds = vec![[f64::MAX, f64::MAX, f64::MIN, f64::MIN]; count]; + for vertex in verts { + let x = vertex.pos[0] as f64 + vertex.pos_low[0] as f64 - origin[0]; + let y = vertex.pos[1] as f64 + vertex.pos_low[1] as f64 - origin[1]; + let local_x = x * cos_r + y * sin_r; + let local_y = -x * sin_r + y * cos_r; + let stride = width + gutter; + let physical = ((local_x - block_left) / stride) + .floor() + .clamp(0.0, count.saturating_sub(1) as f64) as usize; + bounds[physical][0] = bounds[physical][0].min(local_x); + bounds[physical][1] = bounds[physical][1].min(local_y); + bounds[physical][2] = bounds[physical][2].max(local_x); + bounds[physical][3] = bounds[physical][3].max(local_y); + } + let to_world = |x: f64, y: f64| { + [ + origin[0] + x * cos_r - y * sin_r, + origin[1] + x * sin_r + y * cos_r, + ] + }; + bounds + .into_iter() + .filter(|bounds| bounds[0] <= bounds[2] && bounds[1] <= bounds[3]) + .map(|bounds| { + let [left, bottom, right, top] = [ + bounds[0] - pad, + bounds[1] - pad, + bounds[2] + pad, + bounds[3] + pad, + ]; + [ + to_world(left, bottom), + to_world(right, bottom), + to_world(right, top), + to_world(left, top), + ] + }) + .collect() +} + pub(crate) fn explicit_mtext_background(entity: &EntityType) -> Option<[f32; 4]> { let EntityType::MText(text) = entity else { return None; @@ -849,11 +923,12 @@ pub fn tessellate( .unwrap_or(m.rotation); let text_height = m.height * anno; let pad = (m.background_scale - 1.0).max(0.0) * text_height; - let corners = oriented_text_corners( + let corner_groups = oriented_mtext_corner_groups( &sdf_verts, - [m.insertion_point.x, m.insertion_point.y], + m, text_rotation, pad, + anno, ); // Fill / mask — two triangles behind the glyphs. if has_fill { @@ -862,13 +937,18 @@ pub fn tessellate( } else { color_or_inherit(&m.background_color, bg_color) }; - let mut ft = Vec::with_capacity(6); - let mut ftl = Vec::with_capacity(6); - for &k in &[0usize, 1, 2, 0, 2, 3] { - let (h, lo) = - split_ds_xyz(corners[k][0], corners[k][1], elev_v); - ft.push(h); - ftl.push(lo); + let mut ft = Vec::with_capacity(6 * corner_groups.len()); + let mut ftl = Vec::with_capacity(6 * corner_groups.len()); + for corners in &corner_groups { + for &k in &[0usize, 1, 2, 0, 2, 3] { + let (h, lo) = split_ds_xyz( + corners[k][0], + corners[k][1], + elev_v, + ); + ft.push(h); + ftl.push(lo); + } } wires.push(WireModel { point_marker: None, @@ -907,19 +987,24 @@ pub fn tessellate( // Text frame — a closed rectangle in the text // colour around the same box. if has_frame { - let loop_xy = [ - corners[0], - corners[1], - corners[2], - corners[3], - corners[0], - ]; - let mut fp = Vec::with_capacity(5); - let mut fpl = Vec::with_capacity(5); - for &[x, y] in &loop_xy { - let (h, lo) = split_ds_xyz(x, y, elev_v); - fp.push(h); - fpl.push(lo); + let mut fp = Vec::with_capacity(6 * corner_groups.len()); + let mut fpl = Vec::with_capacity(6 * corner_groups.len()); + for (group_index, corners) in corner_groups.iter().enumerate() { + if group_index > 0 { + fp.push([f32::NAN; 3]); + fpl.push([0.0; 3]); + } + for &[x, y] in &[ + corners[0], + corners[1], + corners[2], + corners[3], + corners[0], + ] { + let (h, lo) = split_ds_xyz(x, y, elev_v); + fp.push(h); + fpl.push(lo); + } } wires.push(WireModel { point_marker: None, From b1df6dddd85964c4e508a8d1ff3dd97d670ac70c Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:25 +0300 Subject: [PATCH 11/20] Use MText column persistence revision --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index babff425..32fa742b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=9f3cf8e#9f3cf8e26d5a02fb4ad8ea145ca8e04dd6d6f2bc" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=96dee63f476cd449b95dad19ac89faf29cc19043#96dee63f476cd449b95dad19ac89faf29cc19043" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 02b96ca9..d7575f1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "9f3cf8e", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "96dee63f476cd449b95dad19ac89faf29cc19043", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "9f3cf8e" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "96dee63f476cd449b95dad19ac89faf29cc19043" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } From 1922492a951c3e4012127eaa466a403e59a37596 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:06:51 +0300 Subject: [PATCH 12/20] Make MText defined width read-only --- src/entities/mtext.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index 3dcee115..b8f65ec6 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -300,10 +300,9 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec { // by the line-spacing factor. let line_space_distance = t.height * 1.666_666_666_666_667 * t.line_spacing_factor; let text_frame_on = (t.background_fill_flags & 0x10) != 0; - // Defined width is only live without columns; defined height is live for - // static columns or manual-height dynamic columns, grayed otherwise. + // Defined width is reported by Properties but is not edited there. Defined + // height remains live for static columns or manual-height dynamic columns. let col_type = t.column_data.column_type; - let width_editable = col_type == 0; let height_editable = col_type == 1 || (col_type == 2 && !t.column_data.auto_height); vec![ PropSection { @@ -405,7 +404,7 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec { .collect(), }, }, - num_row(t!("Defined width").as_ref(), "rect_w", t.rectangle_width, width_editable), + num_row(t!("Defined width").as_ref(), "rect_w", t.rectangle_width, false), num_row( t!("Defined height").as_ref(), "rect_h", @@ -540,7 +539,6 @@ fn apply_geom_prop(t: &mut MText, field: &str, value: &str) { "ins_y" => t.insertion_point.y = v, "ins_z" => t.insertion_point.z = v, "height" if v > 0.0 => t.height = v, - "rect_w" if v >= 0.0 => t.rectangle_width = v, "rect_h" if v >= 0.0 => t.rectangle_height = (v > 0.0).then_some(v), "rotation" => t.rotation = v.to_radians(), "line_spacing" if (0.25..=4.0).contains(&v) => t.line_spacing_factor = v, From bbd58eb9f217d27a705728ea1f74b3e14b0228a1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 26 Aug 2026 14:23:08 +0300 Subject: [PATCH 13/20] fix(layout): identify sheet viewports reliably --- src/app/commands/layers.rs | 35 +----- src/app/update/file.rs | 9 +- src/app/update/viewport.rs | 5 +- src/scene/convert/tess.rs | 2 +- src/scene/convert/tessellate.rs | 19 +--- src/scene/mod.rs | 183 ++++++++++++++++++-------------- src/scene/paper.rs | 2 +- 7 files changed, 124 insertions(+), 131 deletions(-) diff --git a/src/app/commands/layers.rs b/src/app/commands/layers.rs index a0220654..9dfc1e77 100644 --- a/src/app/commands/layers.rs +++ b/src/app/commands/layers.rs @@ -827,30 +827,8 @@ impl OpenCADStudio { } } - // A viewport frames the model, so its framing is measured in model - // units and has to follow them. Left alone, every layout would suddenly - // look at a region a thousand times too large. The paper side of the - // ratio is untouched — the sheet is still the same sheet — so the ratio - // itself moves the other way. - // - // `transform_viewport` covers the rectangle and the target but not the - // framing, so the framing is done here for every viewport, and the - // target only for the ones the scale above did not already reach. - // Not every viewport frames the model, though. Each layout's sheet - // viewport frames the sheet — its view is the paper, measured in paper - // units — so scaling it by the model's factor would leave the drawing - // frame the same size as before while the view of it changed by a - // thousand, which is the boundary appearing to break. - let sheets: std::collections::HashSet = self.tabs[i] - .scene - .document - .objects - .values() - .filter_map(|object| match object { - acadrust::objects::ObjectType::Layout(layout) => Some(layout.viewport), - _ => None, - }) - .collect(); + // Model framing follows model units; sheet framing stays in paper units. + let sheets = self.tabs[i].scene.sheet_viewport_handles(); let scaled: std::collections::HashSet = handles.iter().copied().collect(); let mut reframed = 0usize; for entity in self.tabs[i].scene.document.entities_mut() { @@ -858,14 +836,7 @@ impl OpenCADStudio { let acadrust::entities::EntityType::Viewport(vp) = entity else { continue; }; - // Only a paper-space viewport can be a sheet — the scale above - // reached everything in model space, and what it reached frames the - // model by definition. Among the rest, the layout names its sheet - // outright, and a file that arrives without that link still gives - // itself away by sitting at the paper origin, where only the sheet - // sits. - let is_sheet = !scaled.contains(&handle) - && (sheets.contains(&handle) || !crate::scene::Scene::is_content_viewport(vp)); + let is_sheet = !scaled.contains(&handle) && sheets.contains(&handle); if is_sheet { continue; } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 1f77d4bf..bbf906ea 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -204,7 +204,14 @@ fn plot_scene_content( || !crate::scene::Scene::handle_from_wire_name(&wire.name) .and_then(|handle| scene.document.get_entity(handle)) .is_some_and(|entity| { - matches!(entity, acadrust::EntityType::Viewport(viewport) if crate::scene::Scene::is_content_viewport(viewport)) + matches!( + entity, + acadrust::EntityType::Viewport(viewport) + if !crate::scene::Scene::is_sheet_viewport( + &scene.document, + viewport, + ) + ) })) }); model_wires.retain(|wire| wire.plot_visible); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index a527895b..34eddd24 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4620,7 +4620,10 @@ impl OpenCADStudio { if let Some(AcadEntityType::Viewport(vp)) = self.tabs[i].scene.document.get_entity(h) { - if Scene::is_content_viewport(vp) { + if !Scene::is_sheet_viewport( + &self.tabs[i].scene.document, + vp, + ) { Some(h) } else { None diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index d249f80b..dfc58a1d 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -527,7 +527,7 @@ fn tessellate_entity_inner( if let EntityType::Viewport(vp) = e { // The sheet viewport (overall/id=1) is never shown — it represents the // paper boundary, not a user-defined content window. - if !Scene::is_content_viewport(vp) { + if Scene::is_sheet_viewport(document, vp) { return vec![]; } let is_active = active_viewport == Some(h); diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index d474c5f1..c6e5d34a 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -1718,24 +1718,9 @@ pub fn tessellate( // f64 for the WireModel's double-single-era snap buffer. let snap_pts: Vec<(glam::DVec3, SnapHint)> = snap_pts.into_iter().map(|(p, h)| (p.as_dvec3(), h)).collect(); - // A paper-space viewport is a window, not a wireframe: give it an interior - // pick surface so a click anywhere inside the frame selects it. Ranked - // below edge and fill hits, so content drawn inside still wins the click. - // The sheet ("overall") viewport is the layout's own invisible camera - // frame covering the whole page — never pickable, or it would swallow - // every click over the real viewports beneath it. It is identified by the - // Layout object's viewport link (authoritative — DWG files carry id = 0 - // and this file class centres the sheet viewport off-origin, so neither - // the id nor the geometry heuristic alone is reliable), with - // `is_content_viewport` as the fallback classifier. - let is_sheet_vp = |vp: &acadrust::entities::Viewport| { - let h = vp.common.handle; - document.objects.values().any(|obj| { - matches!(obj, acadrust::objects::ObjectType::Layout(l) if l.viewport == h) - }) || !crate::scene::Scene::is_content_viewport(vp) - }; + // Paper viewports are pickable inside their frames; the sheet viewport is not. let (pick_tris, pick_tris_low) = match entity { - EntityType::Viewport(vp) if !is_sheet_vp(vp) => { + EntityType::Viewport(vp) if !crate::scene::Scene::is_sheet_viewport(document, vp) => { if let Some(polygon) = clipped_viewport_polygon.as_ref() { points_to_ds(crate::entities::mesh::triangulate_planar(polygon)) } else { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 313058bc..37c3f209 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2998,32 +2998,96 @@ impl Scene { crate::io::set_saved_active_layout(&mut self.document, &self.current_layout); } - /// Returns true if this viewport should display model-space content - /// (i.e. it is a user viewport, not the sheet/overall viewport). - /// - /// Rules: - /// - id=1 → always the sheet viewport → false - /// - id≥2 → always a user viewport → true - /// - id=0 or id<0 (DWG reader omits the id; some DXF exporters write -1): - /// use geometry: the sheet viewport is centred at the paper origin (0,0) - /// with scale≈1.0 (view_height ≈ paper-space height). - pub fn is_content_viewport(vp: &acadrust::entities::Viewport) -> bool { - if vp.id == 1 { - return false; + pub(crate) fn layout_sheet_viewport_handle( + document: &acadrust::CadDocument, + layout: &acadrust::objects::Layout, + ) -> Handle { + let owned_viewport = |handle| match document.get_entity(handle) { + Some(EntityType::Viewport(vp)) if vp.common.owner_handle == layout.block_record => { + Some(vp) + } + _ => None, + }; + + let listed = layout + .viewports + .iter() + .copied() + .filter_map(|handle| owned_viewport(handle).map(|vp| (handle, vp))); + if let Some((handle, _)) = listed.clone().find(|(_, vp)| vp.id == 1) { + return handle; } - if vp.id > 1 { + if let Some((handle, _)) = listed.into_iter().next() { + return handle; + } + + let block_handles = document + .block_records + .iter() + .find(|block| block.handle == layout.block_record) + .map(|block| block.entity_handles.as_slice()) + .unwrap_or_default(); + let block_viewports = block_handles + .iter() + .copied() + .filter_map(|handle| owned_viewport(handle).map(|vp| (handle, vp))); + if let Some((handle, _)) = block_viewports.clone().find(|(_, vp)| vp.id == 1) { + return handle; + } + if let Some((handle, _)) = block_viewports.into_iter().next() { + return handle; + } + + document + .entities() + .filter_map(|entity| match entity { + EntityType::Viewport(vp) if vp.common.owner_handle == layout.block_record => { + Some(( + vp.common.handle, + vp.id == 1, + vp.width.abs() * vp.height.abs(), + )) + } + _ => None, + }) + .max_by(|a, b| a.1.cmp(&b.1).then_with(|| a.2.total_cmp(&b.2))) + .map(|(handle, _, _)| handle) + .unwrap_or(Handle::NULL) + } + + pub(crate) fn is_sheet_viewport( + document: &acadrust::CadDocument, + vp: &acadrust::entities::Viewport, + ) -> bool { + if vp.id == 1 { return true; } - // id ≤ 0: DWG files never write group-code 69 (viewport id), so all - // viewports arrive with id=0. - // - // In DWG format the sheet ("overall") viewport always has its center at - // the paper-space origin (0, 0). Content viewports are placed at their - // actual position on the paper and therefore have a non-zero center. - // Using center position is more reliable than a scale heuristic because - // the sheet viewport's scale is not always exactly 1:1 (observed: 0.8965 - // in real-world files, which the old 0.02 tolerance missed entirely). - vp.center.x.abs() >= 0.5 || vp.center.y.abs() >= 0.5 + if vp.id > 1 { + return false; + } + document.objects.values().any(|object| { + matches!( + object, + ObjectType::Layout(layout) + if layout.block_record == vp.common.owner_handle + && Self::layout_sheet_viewport_handle(document, layout) + == vp.common.handle + ) + }) + } + + pub(crate) fn sheet_viewport_handles(&self) -> std::collections::HashSet { + self.document + .objects + .values() + .filter_map(|object| match object { + ObjectType::Layout(layout) => { + let handle = Self::layout_sheet_viewport_handle(&self.document, layout); + handle.is_valid().then_some(handle) + } + _ => None, + }) + .collect() } fn current_layout_sheet_viewport_handle(&self) -> Handle { @@ -3035,7 +3099,7 @@ impl Scene { return None; }; if layout.name == self.current_layout { - Some(layout.viewport) + Some(Self::layout_sheet_viewport_handle(&self.document, layout)) } else { None } @@ -3043,82 +3107,49 @@ impl Scene { .unwrap_or(Handle::NULL) } - /// Guarantee that a paper layout has its full-screen overall (`id == 1`) - /// sheet viewport. `add_layout` creates it; this is a safety net for layouts - /// that arrive without it. The sheet - /// viewport is the authoritative paper-space view and the canvas every - /// floating viewport overlays. + /// Ensure a paper layout has its overall (`id == 1`) viewport. pub fn ensure_sheet_viewport(&mut self, layout_name: &str) { if layout_name == "Model" { return; } - // Locate the layout: its object handle, block-record handle, current - // sheet-viewport link, and paper limits. + // Locate the layout and its paper limits. let info = self.document.objects.iter().find_map(|(h, obj)| { if let ObjectType::Layout(l) = obj { if l.name == layout_name { - return Some((*h, l.block_record, l.viewport, l.min_limits, l.max_limits)); + return Some(( + *h, + l.block_record, + Self::layout_sheet_viewport_handle(&self.document, l), + l.min_limits, + l.max_limits, + )); } } None }); - let Some((layout_handle, block_record, cur_vp, min_lim, max_lim)) = info else { + let Some((layout_handle, block_record, sheet, min_lim, max_lim)) = info else { return; }; if block_record.is_null() { return; } - // Normal files carry a valid direct Layout→Viewport link. This O(1) - // path is hit on every ordinary layout-tab switch. - if cur_vp.is_valid() + if sheet.is_valid() && matches!( - self.document.get_entity(cur_vp), + self.document.get_entity(sheet), Some(EntityType::Viewport(vp)) if vp.common.owner_handle == block_record ) { return; } - // Already present? Accept either the linked viewport handle or any - // `id == 1` viewport owned by the layout block. - let has_sheet = self.document.entities().any(|e| { - matches!(e, EntityType::Viewport(vp) - if vp.common.owner_handle == block_record - && (vp.id == 1 || vp.common.handle == cur_vp)) - }); - if has_sheet { - // Keep the layout's link in sync if it was missing. - if !cur_vp.is_valid() { - let h = self.document.entities().find_map(|e| match e { - EntityType::Viewport(vp) - if vp.common.owner_handle == block_record && vp.id == 1 => - { - Some(vp.common.handle) - } - _ => None, - }); - if let Some(h) = h { - if let Some(ObjectType::Layout(l)) = - self.document.objects.get_mut(&layout_handle) - { - l.viewport = h; - } - } - } - return; - } - // Create the full-screen overall viewport covering the paper limits. let pw = (max_lim.0 - min_lim.0).abs().max(1.0); let ph = (max_lim.1 - min_lim.1).abs().max(1.0); let mut vp = acadrust::entities::Viewport::new(); vp.id = 1; vp.status = acadrust::entities::ViewportStatusFlags::default_on(); - // Paper-space center is a 2D (x, y) point with z = 0. Putting the - // paper-height midpoint in z - // (with y = 0) left the sheet view centered at y = 0, shifting the whole - // layout half a page down. See issue #156. + // Paper-space center is an (x, y) point with z = 0. vp.center = acadrust::types::Vector3::new( (min_lim.0 + max_lim.0) / 2.0, (min_lim.1 + max_lim.1) / 2.0, @@ -3126,12 +3157,7 @@ impl Scene { ); vp.width = pw; vp.height = ph; - // Frame the new layout on the whole sheet: look straight down at the - // paper centre with the visible height a touch taller than the page. - // Without this the viewport keeps `Viewport::new`'s default view - // (target 0,0 / height 210), so the first time a fresh drawing's - // layout is opened the camera sits on the paper's bottom-left corner - // instead of centring the sheet. + // Frame the full sheet with a small margin. vp.view_target = acadrust::types::Vector3::new( (min_lim.0 + max_lim.0) / 2.0, (min_lim.1 + max_lim.1) / 2.0, @@ -3144,7 +3170,8 @@ impl Scene { .add_entity_to_layout(EntityType::Viewport(vp), layout_name) { if let Some(ObjectType::Layout(l)) = self.document.objects.get_mut(&layout_handle) { - l.viewport = handle; + l.viewports.retain(|candidate| *candidate != handle); + l.viewports.insert(0, handle); } } } @@ -3161,7 +3188,7 @@ impl Scene { if sheet_handle.is_valid() { vp.common.handle != sheet_handle } else { - Self::is_content_viewport(vp) + !Self::is_sheet_viewport(&self.document, vp) } } diff --git a/src/scene/paper.rs b/src/scene/paper.rs index db2a4e76..d7a1d928 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -29,7 +29,7 @@ impl Scene { && if sheet.is_valid() { handle != sheet } else { - Self::is_content_viewport(vp) + !Self::is_sheet_viewport(&self.document, vp) } }; let content = if let Some(block) = self From 0754c928ce73d102b2385f32a2ba423eadfc6b97 Mon Sep 17 00:00:00 2001 From: gianlucafiore Date: Wed, 26 Aug 2026 15:15:03 -0300 Subject: [PATCH 14/20] improve ALIGN command interaction and preview --- src/app/commands/inquiry.rs | 7 +- src/modules/draw/modify/align.rs | 115 ++++++++++++++++++++++++++++--- 2 files changed, 110 insertions(+), 12 deletions(-) diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index b701f91d..a49778ca 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -943,7 +943,12 @@ impl OpenCADStudio { "ALIGN" => { use crate::modules::draw::modify::align::AlignCommand; - let cmd = AlignCommand::new(); + + let selected: Vec = + self.tabs[i].scene.selected.iter().copied().collect(); + + let cmd = AlignCommand::with_selection(selected); + self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } diff --git a/src/modules/draw/modify/align.rs b/src/modules/draw/modify/align.rs index ad6122ef..8cd4109c 100644 --- a/src/modules/draw/modify/align.rs +++ b/src/modules/draw/modify/align.rs @@ -14,6 +14,7 @@ use glam::DVec3; use crate::t; use crate::command::{CadCommand, CmdResult, EntityTransform}; +use crate::scene::model::wire_model::WireModel; pub struct AlignCommand { state: AlignState, @@ -35,10 +36,16 @@ enum AlignState { } impl AlignCommand { - pub fn new() -> Self { + pub fn with_selection(handles: Vec) -> Self { + let state = if handles.is_empty() { + AlignState::Gathering + } else { + AlignState::Src1 + }; + Self { - state: AlignState::Gathering, - handles: vec![], + state, + handles, src1: None, dst1: None, src2: None, @@ -66,11 +73,26 @@ impl CadCommand for AlignCommand { } AlignState::Dst2 => t!("ALIGN Specify 2nd destination point:").into_owned(), AlignState::AskScale => { - t!("ALIGN Scale objects based on alignment points? [Y/N]:").into_owned() + t!( + "ALIGN Scale objects based on alignment points? [Yes / No]:" + ) + .into_owned() } } } + fn options(&self) -> Vec { + use crate::command::CmdOption; + + match self.state { + AlignState::AskScale => vec![ + CmdOption::new(t!("Yes").as_ref(), "Y"), + CmdOption::new(t!("No").as_ref(), "N"), + ], + _ => vec![], + } + } + fn is_selection_gathering(&self) -> bool { self.state == AlignState::Gathering } @@ -113,14 +135,17 @@ impl CadCommand for AlignCommand { if self.handles.is_empty() { return CmdResult::Cancel; } + self.state = AlignState::Src1; CmdResult::NeedPoint } + AlignState::Src2 => { - // Only 1 pair — pure translation + // One alignment pair only: translation. match (self.src1, self.dst1) { (Some(s), Some(d)) => { let delta = d - s; + CmdResult::TransformSelected( self.handles.clone(), EntityTransform::Translate(delta), @@ -129,10 +154,10 @@ impl CadCommand for AlignCommand { _ => CmdResult::Cancel, } } - AlignState::AskScale => { - // No scale (default N) - self.compute_align(false) - } + + // Default option shown as . + AlignState::AskScale => self.compute_align(false), + _ => CmdResult::Cancel, } } @@ -145,8 +170,76 @@ impl CadCommand for AlignCommand { if self.state != AlignState::AskScale { return None; } - let scale = text.trim().to_uppercase().starts_with('Y'); - Some(self.compute_align(scale)) + + match text.trim().to_ascii_lowercase().as_str() { + "y" | "yes" | "scale" => { + Some(self.compute_align(true)) + } + + "n" | "no" | "don't scale" | "dont scale" | "noscale" => { + Some(self.compute_align(false)) + } + + _ => Some(CmdResult::NeedPoint), + } + } + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + fn line(a: DVec3, b: DVec3, name: &str) -> WireModel { + WireModel::solid( + name.into(), + vec![ + [a.x as f32, a.y as f32, a.z as f32], + [b.x as f32, b.y as f32, b.z as f32], + ], + WireModel::CYAN, + false, + ) + } + + let mut out = Vec::new(); + + // Keep the first completed alignment pair visible while defining + // the second pair and while choosing the scale option. + if let (Some(src1), Some(dst1)) = (self.src1, self.dst1) { + match self.state { + AlignState::Src2 + | AlignState::Dst2 + | AlignState::AskScale => { + out.push(line(src1, dst1, "align_pair_1")); + } + _ => {} + } + } + + match self.state { + // First source has been picked: stretch its reference line + // to the cursor until the first destination is chosen. + AlignState::Dst1 => { + if let Some(src1) = self.src1 { + out.push(line(src1, pt, "align_pair_1_preview")); + } + } + + // Second source has been picked: stretch the second reference + // line to the cursor until its destination is chosen. + AlignState::Dst2 => { + if let Some(src2) = self.src2 { + out.push(line(src2, pt, "align_pair_2_preview")); + } + } + + // Once both pairs are complete, keep both visible while + // waiting for the Scale / No Scale decision. + AlignState::AskScale => { + if let (Some(src2), Some(dst2)) = (self.src2, self.dst2) { + out.push(line(src2, dst2, "align_pair_2")); + } + } + + _ => {} + } + + out } } From cfe1ad00068878415fd1aa7a82bbeb07d61720cf Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 26 Aug 2026 21:48:02 +0300 Subject: [PATCH 15/20] perf(selection): cache plugin fingerprint --- src/app/mod.rs | 2 +- src/app/update/mod.rs | 10 +---- src/scene/group_layer.rs | 5 ++- src/scene/mod.rs | 11 +++++ src/scene/selection.rs | 91 ++++++++++++++++++++++------------------ 5 files changed, 69 insertions(+), 50 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index a82dc50e..850220ff 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -679,7 +679,7 @@ pub(super) struct OpenCADStudio { /// keep their manifest listed but drop their ribbon tab and command /// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`]. disabled_plugins: rustc_hash::FxHashSet, - /// `(tab id, selection signature)` last broadcast to V4 plugins, so + /// `(tab id, selection fingerprint)` last broadcast to V4 plugins, so /// `SelectionChangedV4` fires once per real change rather than per message. #[cfg(not(target_arch = "wasm32"))] last_plugin_selection: Option<(u64, u64)>, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 2a947376..24b59350 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -233,12 +233,6 @@ impl OpenCADStudio { /// Emit `SelectionChangedV4` to V4 plugins when the active tab's selection /// set actually changed since the last broadcast. - /// - /// Checked at the message boundary rather than at the mutation sites so - /// every path is covered: picking, window select, QSELECT, SELECTALL, - /// grip edits that drop the selection, and the automation `select` op. - /// The signature comparison keeps hover and repeated no-op selects from - /// producing spurious notifications. #[cfg(not(target_arch = "wasm32"))] pub(super) fn notify_plugins_selection_changed(&mut self) { if self.active_tab >= self.tabs.len() { @@ -246,8 +240,8 @@ impl OpenCADStudio { } let i = self.active_tab; let tab_id = self.tabs[i].id; - let sig = self.tabs[i].scene.selection_sig(); - let key = (tab_id, sig); + let fingerprint = self.tabs[i].scene.selection_fingerprint(); + let key = (tab_id, fingerprint); if self.last_plugin_selection == Some(key) { return; } diff --git a/src/scene/group_layer.rs b/src/scene/group_layer.rs index af957834..2c3aa78e 100644 --- a/src/scene/group_layer.rs +++ b/src/scene/group_layer.rs @@ -203,8 +203,11 @@ impl Scene { /// If any handle belongs to a selectable group, also select every member. pub fn expand_selection_for_groups(&mut self, handles: &[Handle]) { + let previous_len = self.selected.len(); self.selected .extend(self.handles_expanded_for_selectable_groups(handles)); - self.bump_selection(); + if self.selected.len() != previous_len { + self.bump_selection_set(); + } } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index cf14edc6..2347fb53 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1486,6 +1486,9 @@ pub struct Scene { /// pick only refreshes the GPU xray overlay (cheap) instead of bumping /// `geometry_epoch` and re-tessellating the whole model. pub selection_generation: u64, + /// Cached fingerprint of `selected`, recomputed lazily after mutations. + selection_fingerprint_cache: u64, + selection_fingerprint_dirty: bool, /// Cached tessellation of all visible entity wires for the current layout. /// Keyed by `(geometry_epoch, camera_generation)` so a camera change /// invalidates the cull-dependent wire list as well as a geometry change. @@ -1861,6 +1864,8 @@ impl Scene { projection_bounds_epoch: std::cell::Cell::new(0), block_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed), selection_generation: 0, + selection_fingerprint_cache: 0, + selection_fingerprint_dirty: false, wire_cache: RefCell::new(None), interaction_index_cache: RefCell::new(Vec::new()), interaction_index_pending_key: std::cell::Cell::new(None), @@ -2610,6 +2615,11 @@ impl Scene { self.selection_generation = self.selection_generation.wrapping_add(1); } + pub(crate) fn bump_selection_set(&mut self) { + self.selection_fingerprint_dirty = true; + self.bump_selection(); + } + /// Milliseconds after the last camera change during which the view counts as /// "actively navigating" for interaction-LOD purposes. const NAV_SETTLE_MS: u128 = 130; @@ -4398,6 +4408,7 @@ impl Scene { .extend(self.selected.iter().copied()); self.selected.clear(); self.selected_order.clear(); + self.bump_selection_set(); self.bump_entities(&changes); } diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 195e071e..aa951bdd 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -40,8 +40,11 @@ impl Scene { } pub fn select_entity(&mut self, handle: Handle, exclusive: bool) { let handles = self.handles_expanded_for_leader_annotations(&[handle]); + let mut changed = false; if exclusive { + changed = self.selected.len() != handles.len() + || handles.iter().any(|handle| !self.selected.contains(handle)); self.selected.clear(); self.selected_order.clear(); } @@ -49,30 +52,35 @@ impl Scene { for handle in handles { if self.selected.insert(handle) { self.selected_order.push(handle); + changed = true; } } - self.bump_selection(); + if changed { + self.bump_selection_set(); + } } pub fn deselect_all(&mut self) { + if self.selected.is_empty() { + return; + } self.selected.clear(); self.selected_order.clear(); - self.bump_selection(); + self.bump_selection_set(); } - /// Order-independent signature of the current selection set. - /// - /// `selection_generation` cannot be used for change detection because it - /// also bumps on hover. This signature combines a per-handle hash with XOR - /// so it is allocation-free, O(n), and insensitive to selection order. - pub(crate) fn selection_sig(&self) -> u64 { - let mut combined = 0u64; - for handle in &self.selected { - let mut hasher = DefaultHasher::new(); - handle.hash(&mut hasher); - combined ^= hasher.finish(); + pub(crate) fn selection_fingerprint(&mut self) -> u64 { + if self.selection_fingerprint_dirty { + let mut fingerprint = self.selected.len() as u64; + for handle in &self.selected { + let mut hasher = DefaultHasher::new(); + handle.hash(&mut hasher); + fingerprint ^= hasher.finish(); + } + self.selection_fingerprint_cache = fingerprint; + self.selection_fingerprint_dirty = false; } - combined + self.selection_fingerprint_cache } pub(crate) fn selected_handles_in_order(&self) -> Vec { @@ -121,7 +129,7 @@ impl Scene { order.extend(added); self.selected = selected; self.selected_order = order; - self.bump_selection(); + self.bump_selection_set(); } } @@ -136,7 +144,7 @@ impl Scene { } if changed { - self.bump_selection(); + self.bump_selection_set(); } } @@ -217,7 +225,7 @@ impl Scene { } } if added > 0 { - self.bump_selection(); + self.bump_selection_set(); } added } @@ -241,7 +249,9 @@ impl Scene { self.selected_order.push(h); } } - self.bump_selection(); + if self.selected != prev { + self.bump_selection_set(); + } self.selected.len() } @@ -727,7 +737,8 @@ impl Scene { let mut handle_set: HashSet = HashSet::default(); let mut erased: Vec<(Handle, ChangeKind)> = Vec::new(); - let mut highlight_changed = false; + let mut selection_changed = false; + let mut hover_changed = false; for &h in &erase_handles { // Objects on a locked layer can't be erased. @@ -742,11 +753,11 @@ impl Scene { self.delete_solid_history(h); self.remember_removed_cache_categories(h); self.document.remove_entity_arc(h); - highlight_changed |= self.selected.remove(&h); + selection_changed |= self.selected.remove(&h); self.selected_order.retain(|selected| *selected != h); if self.hover_highlight == Some(h) { self.hover_highlight = None; - highlight_changed = true; + hover_changed = true; } self.hatches.remove(&h); self.images.remove(&h); @@ -756,7 +767,9 @@ impl Scene { handle_set.insert(h); erased.push((h, ChangeKind::Removed)); } - if highlight_changed { + if selection_changed { + self.bump_selection_set(); + } else if hover_changed { self.bump_selection(); } // Capture exactly the group objects that this erase will rewrite, plus @@ -862,25 +875,23 @@ mod tests { use super::*; #[test] - fn selection_sig_is_order_independent() { - let mut scene_a = Scene::default(); - let mut scene_b = Scene::default(); - let h1 = Handle::new(1); - let h2 = Handle::new(2); - scene_a.select_entity(h1, false); - scene_a.select_entity(h2, false); - scene_b.select_entity(h2, false); - scene_b.select_entity(h1, false); - assert_eq!(scene_a.selection_sig(), scene_b.selection_sig()); - } - - #[test] - fn selection_sig_changes_when_selection_changes() { + fn selection_fingerprint_tracks_final_set_only() { let mut scene = Scene::default(); - let empty_sig = scene.selection_sig(); - scene.select_entity(Handle::new(1), false); - assert_ne!(scene.selection_sig(), empty_sig); + let first = Handle::new(1); + let second = Handle::new(2); + scene.select_entity(first, false); + scene.select_entity(second, false); + let fingerprint = scene.selection_fingerprint(); + assert!(!scene.selection_fingerprint_dirty); + scene.deselect_all(); - assert_eq!(scene.selection_sig(), empty_sig); + scene.select_entity(second, false); + scene.select_entity(first, false); + assert!(scene.selection_fingerprint_dirty); + assert_eq!(scene.selection_fingerprint(), fingerprint); + + scene.set_hover_highlight(Some(Handle::new(3))); + assert!(!scene.selection_fingerprint_dirty); + assert_eq!(scene.selection_fingerprint(), fingerprint); } } From 4053f2c3810ed74e951fb59e4362a88b70505b8f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 26 Aug 2026 21:48:02 +0300 Subject: [PATCH 16/20] docs(plugin): fix runner connection flow --- crates/ocs_plugin_api/ARCHITECTURE.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md index 19388eb7..20e77acc 100644 --- a/crates/ocs_plugin_api/ARCHITECTURE.md +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -61,20 +61,23 @@ The runtime enforces two gates: ```mermaid sequenceDiagram - participant H as Host process - participant PM as PluginManager - participant PP as PluginProcess + participant PM as PluginManager (host) + participant PP as PluginProcess (host) participant R as Runner child participant L as Plugin cdylib PM->>PP: spawn(cdylib_path, host) - PP->>H: spawn --ocs-plugin-runner - H->>R: exec - R->>R: create local socket listener + PP->>PP: create local socket listener + PP->>R: spawn --ocs-plugin-runner R->>L: unsafe { load(cdylib_path) } L-->>R: Box - R->>PP: connect + RunnerHandshake::Token - PP->>PP: verify OCS_PLUGIN_TOKEN + alt API v4 + R->>PP: connect + RunnerHandshake::TokenV4 + PP->>PP: verify token + V4 protocol/API gate + else API v2/v3 + R->>PP: connect + RunnerHandshake::Token + PP->>PP: verify token + end PP->>R: HostToPlugin::Request(GetManifest) R->>L: manifest() L-->>R: PluginManifest { api_version } From 79f045ce68e9269bd3705f4ecc4dd4209c2157ce Mon Sep 17 00:00:00 2001 From: gianlucafiore Date: Wed, 26 Aug 2026 15:58:36 -0300 Subject: [PATCH 17/20] feat(offset): add reference distance input --- src/modules/draw/modify/offset.rs | 88 ++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 14 deletions(-) diff --git a/src/modules/draw/modify/offset.rs b/src/modules/draw/modify/offset.rs index d3b5cc0a..20a988ac 100644 --- a/src/modules/draw/modify/offset.rs +++ b/src/modules/draw/modify/offset.rs @@ -415,19 +415,19 @@ fn entity_wire_pts(e: &EntityType) -> Vec<[f32; 3]> { // ── Command implementation ───────────────────────────────────────────────── enum Step { - /// Classic first step (#418): type the offset distance, press Enter / - /// Space to accept the last one, or choose Through mode. + /// Type the offset distance, accept the previous value with Enter, + /// choose Through, or pick the first point of a reference distance. Distance, - /// Pick the object to offset. `locked == None` is "through" mode: the - /// magnitude follows the cursor (perpendicular distance to the object). + + /// First reference point has been picked; the second point defines + /// the offset distance. + ReferenceSecond { first: DVec3 }, + SelectObject { locked: Option }, + PickSide { - /// The object(s) being offset — one from a pick, or the whole - /// pre-selection when OFFSET starts with objects selected (#422). targets: Vec, locked: Option, - /// Keep the side-pick step active and use each new result as the source - /// for the next offset. multiple: bool, }, } @@ -499,6 +499,18 @@ impl OffsetCommand { } CmdResult::NeedPoint } + fn accept_distance(&mut self, distance: f64) -> CmdResult { + let distance = distance.abs().max(1e-9); + + defaults::set_offset_dist(distance); + self.advance_from_distance(Some(distance)); + + let d = format!("{:.4}", distance); + + CmdResult::ReportMeasurement( + t!("OFFSET distance = %{d}", d = d).into_owned() + ) + } } impl CadCommand for OffsetCommand { @@ -510,7 +522,15 @@ impl CadCommand for OffsetCommand { match &self.step { Step::Distance => { let d = format!("{:.4}", defaults::get_offset_dist()); - t!("OFFSET Specify offset distance or [Through] <%{d}>:", d = d).into_owned() + t!( + "OFFSET Specify offset distance or first reference point [Through] <%{d}>:", + d = d + ) + .into_owned() + } + + Step::ReferenceSecond { .. } => { + t!("OFFSET Specify second reference point:").into_owned() } Step::SelectObject { .. } => { t!("OFFSET Select object to offset (Enter to finish):").into_owned() @@ -623,7 +643,9 @@ impl CadCommand for OffsetCommand { fn dyn_field(&self) -> crate::command::DynField { match self.step { Step::Distance | Step::PickSide { .. } => crate::command::DynField::Scalar, - _ => crate::command::DynField::Point, + + Step::ReferenceSecond { .. } + | Step::SelectObject { .. } => crate::command::DynField::Point, } } @@ -648,9 +670,7 @@ impl CadCommand for OffsetCommand { return Some(self.advance_from_distance(None)); } if let Some(d) = crate::entities::common::parse_typed_length(&t) { - let d = d.abs().max(1e-9); - defaults::set_offset_dist(d); - return Some(self.advance_from_distance(Some(d))); + return Some(self.accept_distance(d)); } Some(CmdResult::NeedPoint) } @@ -698,12 +718,32 @@ impl CadCommand for OffsetCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + match &self.step { + Step::Distance => { + self.step = Step::ReferenceSecond { first: pt }; + return CmdResult::NeedPoint; + } + + Step::ReferenceSecond { first } => { + let distance = first.distance(pt); + + if distance <= 1e-9 { + return CmdResult::NeedPoint; + } + + return self.accept_distance(distance); + } + + _ => {} + } + let (locked, targets, multiple) = match &self.step { Step::PickSide { locked, targets, multiple, } => (*locked, targets.clone(), *multiple), + _ => return CmdResult::NeedPoint, }; // Each target offsets by its own through-distance (or the locked @@ -745,6 +785,26 @@ impl CadCommand for OffsetCommand { } fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + if let Step::ReferenceSecond { first } = &self.step { + return vec![WireModel::solid( + "offset_reference_distance".into(), + vec![ + [ + first.x as f32, + first.y as f32, + first.z as f32, + ], + [ + pt.x as f32, + pt.y as f32, + pt.z as f32, + ], + ], + WireModel::CYAN, + false, + )]; + } + let (locked, targets) = match &self.step { Step::PickSide { locked, targets, .. } => (*locked, targets.clone()), _ => return vec![], @@ -779,7 +839,7 @@ impl CadCommand for OffsetCommand { // the "repeat the same value with just Space" flow (#418). Step::Distance => { let d = defaults::get_offset_dist(); - self.advance_from_distance(Some(d.abs().max(1e-9))) + self.accept_distance(d) } _ => CmdResult::Cancel, } From 0603aae28687346eb58711dcbf6ee5c3e607c8e1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 26 Aug 2026 22:21:40 +0300 Subject: [PATCH 18/20] fix(text): correct scaled text placement Derive two-point layout and continuation spacing from the effective annotation scale. Keep style flags out of per-entity mirror overrides. --- src/app/commands/draw.rs | 14 +++++++++---- src/app/properties.rs | 31 +++++++++++++++++---------- src/app/text_inline.rs | 37 ++++++++++++++++++++------------- src/command.rs | 2 ++ src/entities/text.rs | 24 ++++++++++++++++----- src/modules/annotate/text.rs | 35 ++++++++++++++++++++++++------- src/scene/convert/tessellate.rs | 9 +++++++- src/scene/creation_style.rs | 4 ---- 8 files changed, 109 insertions(+), 47 deletions(-) diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 4c2b3814..72ccacca 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1268,10 +1268,16 @@ impl OpenCADStudio { // ── Annotate commands ────────────────────────────────────────── "TEXT" => { use crate::modules::annotate::text::TextCommand; - let document = &self.tabs[i].scene.document; - let defaults = crate::scene::creation_style::current_text_defaults(document); - let styles = document.text_styles.iter().cloned().collect(); - let new_cmd = TextCommand::with_defaults(defaults, styles); + let (defaults, styles, annotation_multiplier) = { + let scene = &self.tabs[i].scene; + let annotation_multiplier = scene.creation_annotation_multiplier(); + let defaults = + crate::scene::creation_style::current_text_defaults(&scene.document); + let styles = scene.document.text_styles.iter().cloned().collect(); + (defaults, styles, annotation_multiplier) + }; + let new_cmd = + TextCommand::with_defaults(defaults, styles, annotation_multiplier); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/app/properties.rs b/src/app/properties.rs index 43d3d463..fde96f93 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1809,12 +1809,30 @@ impl OpenCADStudio { text.horizontal_alignment, acadrust::entities::TextHorizontalAlignment::Aligned ); + let annotative = crate::scene::annotative::is_annotative(doc, entity); + let model_factor = if annotative { + annotation_scale_handle + .and_then(|handle| match doc.objects.get(&handle) { + Some(acadrust::objects::ObjectType::Scale(scale)) => Some( + scale.inverse_factor() + / self.tabs[i].scene.annotation_scale_unit_factor(), + ), + _ => None, + }) + .unwrap_or(self.tabs[i].scene.annotation_scale as f64) + } else { + 1.0 + }; let paper_height = if aligned { - crate::entities::text::text_run_placement(text, doc).height as f64 + crate::entities::text::text_run_placement_at_scale( + text, + doc, + model_factor as f32, + ) + .height as f64 } else { text.height }; - let annotative = crate::scene::annotative::is_annotative(doc, entity); for section in sections.iter_mut() { if let Some(row) = section.props.iter_mut().find(|row| row.field == "height") @@ -1830,15 +1848,6 @@ impl OpenCADStudio { } } if annotative { - let model_factor = annotation_scale_handle - .and_then(|handle| match doc.objects.get(&handle) { - Some(acadrust::objects::ObjectType::Scale(scale)) => Some( - scale.inverse_factor() - / self.tabs[i].scene.annotation_scale_unit_factor(), - ), - _ => None, - }) - .unwrap_or(self.tabs[i].scene.annotation_scale as f64); insert_row_after( &mut sections, "height", diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs index b08297fc..4af0863f 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -234,6 +234,7 @@ impl super::OpenCADStudio { } else { crate::command::WorkingPlane::default() }; + let command_creation = ed.creation.is_some(); let mut t = if let Some(mut prepared) = ed.creation { prepared.value = ed.value.clone(); prepared @@ -258,10 +259,26 @@ impl super::OpenCADStudio { t.style = cur_style; } } - let annotative = crate::scene::annotative::text_style_is_annotative( - &self.tabs[i].scene.document, - &t.style, - ); + if command_creation { + let annotation_multiplier = if crate::scene::annotative::text_style_is_annotative( + &self.tabs[i].scene.document, + &t.style, + ) { + self.tabs[i].scene.creation_annotation_multiplier() + } else { + 1.0 + }; + let display_height = crate::entities::text::text_run_placement_at_scale( + &t, + &self.tabs[i].scene.document, + annotation_multiplier as f32, + ) + .height as f64 + * annotation_multiplier; + if let Some(command) = self.tabs[i].suspended_cmd.as_mut() { + command.on_editor_display_height(display_height); + } + } self.push_undo_snapshot(i, "TEXT"); self.tabs[i].scene.document.header.current_text_style_name = t.style.clone(); let variable_height = self.tabs[i] @@ -279,17 +296,7 @@ impl super::OpenCADStudio { { self.tabs[i].scene.document.header.text_height = t.height; } - let handle = self.commit_entity_handle(plane.place_entity(EntityType::Text(t))); - if annotative { - let scale = self.tabs[i].scene.current_annotation_scale_handle(); - if let (Some(handle), Some(scale)) = (handle, scale) { - crate::scene::annotative::create_annotation_context( - &mut self.tabs[i].scene.document, - handle, - scale, - ); - } - } + let _ = self.commit_entity_handle(plane.place_entity(EntityType::Text(t))); self.tabs[i].dirty = true; } self.refresh_properties(); diff --git a/src/command.rs b/src/command.rs index 42d81896..c309819d 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1869,6 +1869,8 @@ pub trait CadCommand: Send { /// Resume the command with collected rich text. fn on_editor_text(&mut self, _value: String) {} + fn on_editor_display_height(&mut self, _height: f64) {} + /// Called when the user clicks and `needs_entity_pick()` is true. /// `handle` is the nearest wire's entity handle (Handle::NULL if nothing found). fn on_entity_pick(&mut self, _handle: Handle, _pt: DVec3) -> CmdResult { diff --git a/src/entities/text.rs b/src/entities/text.rs index 48e1ca0e..8e4a504e 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -130,8 +130,12 @@ pub(crate) fn acad_text_encode(value: &str) -> String { out } -fn to_render(t: &Text, document: &acadrust::CadDocument) -> RenderEntity { - let p = text_run_placement(t, document); +pub(crate) fn to_render_at_scale( + t: &Text, + document: &acadrust::CadDocument, + annotation_scale: f32, +) -> RenderEntity { + let p = text_run_placement_at_scale(t, document, annotation_scale); let snap_pt = glam::DVec3::new(p.wcs_insertion[0], p.wcs_insertion[1], p.wcs_insertion[2]); // Parse `%%` codes via acadrust, re-encoded for the stroke tessellator. let value = acad_text_encode(&p.value); @@ -172,7 +176,16 @@ fn to_render(t: &Text, document: &acadrust::CadDocument) -> RenderEntity { /// Compute a TEXT entity's run placement (origin + layout params). Extracted /// from `to_render` verbatim so the stroke and SDF-quad paths agree exactly. -pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPlacement { +pub fn text_run_placement_at_scale( + t: &Text, + document: &acadrust::CadDocument, + annotation_scale: f32, +) -> TextPlacement { + let annotation_scale = if annotation_scale.is_finite() && annotation_scale > 1.0e-9 { + annotation_scale + } else { + 1.0 + }; let normal = (t.normal.x, t.normal.y, t.normal.z); let (wsx, wsy, wsz) = crate::scene::view::transform::ocs_point_to_wcs( ( @@ -222,7 +235,8 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla oblique_angle, ) { if base_bounds.advance > 1.0e-6 { - let scale = (span as f32 / base_bounds.advance).max(1.0e-6); + let scale = + (span as f32 / annotation_scale / base_bounds.advance).max(1.0e-6); if matches!(t.horizontal_alignment, HA::Aligned) { height *= scale; } else { @@ -605,7 +619,7 @@ fn apply_transform(t: &mut Text, tr: &EntityTransform) { impl RenderConvertible for Text { fn to_render(&self, document: &acadrust::CadDocument) -> Option { - Some(to_render(self, document)) + Some(to_render_at_scale(self, document, 1.0)) } } diff --git a/src/modules/annotate/text.rs b/src/modules/annotate/text.rs index 041394cb..90d815a6 100644 --- a/src/modules/annotate/text.rs +++ b/src/modules/annotate/text.rs @@ -47,12 +47,17 @@ pub struct TextCommand { oblique_angle: f64, fixed_height: bool, annotative: bool, - generation_flags: i16, + annotation_multiplier: f64, + last_display_height: Option, last_entity: Option, } impl TextCommand { - pub fn with_defaults(defaults: TextCreationDefaults, styles: Vec) -> Self { + pub fn with_defaults( + defaults: TextCreationDefaults, + styles: Vec, + annotation_multiplier: f64, + ) -> Self { let current_height = defaults.height; let mut command = Self { step: Step::Start, @@ -69,7 +74,8 @@ impl TextCommand { oblique_angle: defaults.oblique_angle, fixed_height: false, annotative: false, - generation_flags: 0, + annotation_multiplier: annotation_multiplier.max(1.0e-9), + last_display_height: None, last_entity: None, }; let style = command.style_name.clone(); @@ -102,8 +108,6 @@ impl TextCommand { 85.0_f64.to_radians(), ); self.annotative = style.annotative; - self.generation_flags = (if style.flags.backward { 2 } else { 0 }) - | (if style.flags.upside_down { 4 } else { 0 }); true } @@ -166,7 +170,6 @@ impl TextCommand { text.rotation = self.rotation; text.horizontal_alignment = self.horizontal; text.vertical_alignment = self.vertical; - text.generation_flags = self.generation_flags; text.alignment_point = if self.is_two_point() { let second = self.plane.to_local(self.second_point?); Some(Vector3::new(second.x, second.y, second.z)) @@ -183,6 +186,7 @@ impl TextCommand { return CmdResult::NeedPoint; }; let pos = self.first_point.unwrap_or(DVec3::ZERO); + self.last_display_height = None; self.last_entity = Some(entity.clone()); CmdResult::SuspendForTextInput { pos, entity } } @@ -199,7 +203,18 @@ impl TextCommand { } else { entity.rotation }; - let spacing = entity.height.max(1.0e-9) * 1.666_666_666_7; + let fallback_height = entity.height.max(1.0e-9) + * if self.annotative { + self.annotation_multiplier + } else { + 1.0 + }; + let spacing = self + .last_display_height + .take() + .unwrap_or(fallback_height) + .max(1.0e-9) + * 1.666_666_666_7; let delta = Vector3::new(angle.sin() * spacing, -angle.cos() * spacing, 0.0); entity.insertion_point = entity.insertion_point + delta; if let Some(point) = entity.alignment_point.as_mut() { @@ -422,6 +437,12 @@ impl CadCommand for TextCommand { } } + fn on_editor_display_height(&mut self, height: f64) { + if height.is_finite() && height > 1.0e-9 { + self.last_display_height = Some(height); + } + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index d474c5f1..a50f35c4 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -633,7 +633,14 @@ pub fn tessellate( // stay a roughly constant on-screen size; otherwise the header-driven path. let te = crate::entities::point::relative_render(entity, document, world_per_pixel) .or_else(|| crate::entities::light::relative_render(entity, document, world_per_pixel)) - .or_else(|| convert(entity, document)); + .or_else(|| match entity { + EntityType::Text(text) => Some(crate::entities::text::to_render_at_scale( + text, + document, + anno_scale, + )), + _ => convert(entity, document), + }); if let Some(te) = te { match te.object { // ── Text / MText: pre-tessellated glyph strokes ─────────────── diff --git a/src/scene/creation_style.rs b/src/scene/creation_style.rs index ed4a1fc5..bfc4ac5c 100644 --- a/src/scene/creation_style.rs +++ b/src/scene/creation_style.rs @@ -134,10 +134,6 @@ fn apply_text_defaults(doc: &CadDocument, entity: &mut EntityType) { if text.oblique_angle.abs() <= 1.0e-9 { text.oblique_angle = resolved.oblique_angle; } - if text.generation_flags == 0 { - text.generation_flags = (if resolved.flags.backward { 2 } else { 0 }) - | (if resolved.flags.upside_down { 4 } else { 0 }); - } } EntityType::MText(text) => { if resolved.height > 1.0e-9 { From e4364b4f1d6a774272181669cf08c1392926b782 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 26 Aug 2026 22:57:22 +0300 Subject: [PATCH 19/20] feat(branding): update application logo --- assets/logo.svg | 169 ++++++------------------------------------------ 1 file changed, 20 insertions(+), 149 deletions(-) diff --git a/assets/logo.svg b/assets/logo.svg index e6bdb30b..7b4fdc6b 100644 --- a/assets/logo.svg +++ b/assets/logo.svg @@ -1,150 +1,21 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + From 24b0fdd8e5ac820f2d05fbec4e6fcd1a94ca249f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 26 Aug 2026 23:35:01 +0300 Subject: [PATCH 20/20] build(deps): update cadcodec revision --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32fa742b..f3d5fa66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=96dee63f476cd449b95dad19ac89faf29cc19043#96dee63f476cd449b95dad19ac89faf29cc19043" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=9da074c3a446759fb500c7e836bc5496c568ba84#9da074c3a446759fb500c7e836bc5496c568ba84" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index d7575f1f..579d350d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "96dee63f476cd449b95dad19ac89faf29cc19043", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "9da074c3a446759fb500c7e836bc5496c568ba84", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "96dee63f476cd449b95dad19ac89faf29cc19043" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "9da074c3a446759fb500c7e836bc5496c568ba84" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] }