diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md new file mode 100644 index 000000000..20e77acc8 --- /dev/null +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -0,0 +1,264 @@ +# `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 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->>PP: create local socket listener + PP->>R: spawn --ocs-plugin-runner + R->>L: unsafe { load(cdylib_path) } + L-->>R: Box + 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 } + 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 939518162..25021c8ec 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/REPORT.md b/crates/ocs_plugin_api/REPORT.md deleted file mode 100644 index 1509fd1c5..000000000 --- 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. diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index eb2220b9b..f146c23ab 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 c6341338f..3674ea8ff 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 5ea05e152..44e7e02e9 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 c8735a30d..5b494026a 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 69e42ce6f..3cc1296d1 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 6eda56055..a491a6ebc 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 e67788bde..3a27ac7ed 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 2d339b5d2..34e97c2e9 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 e721983e0..9b4309220 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 0d4593411..39352e8ae 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 9a26d9581..7535af350 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 d2fec1e40..003f6ff02 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 9843c3bba..d2fc60154 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 6b4a60c6b..03f0097fb 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 ebada4e5a..4203ce406 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 a563d715a..ce55abce8 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 1df0bc308..35c870cde 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 b3a5e5a41..dc1a8d0aa 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 f72c30487..fddac688f 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/command_driver.rs b/src/app/command_driver.rs index ea345814a..09eb09718 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3749,6 +3749,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 1fab3abf2..d9edbc862 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1268,11 +1268,16 @@ 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 (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/commands/inquiry.rs b/src/app/commands/inquiry.rs index b701f91da..a49778ca1 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/app/commands/layers.rs b/src/app/commands/layers.rs index a02206548..9dfc1e77c 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/mod.rs b/src/app/mod.rs index 7223af755..40f1b4ca5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -691,6 +691,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 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)>, /// External add-on packages found in the plugins folder, refreshed when the /// Plugin Manager opens. external_plugins: Vec, @@ -3271,6 +3275,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/mtext_editor.rs b/src/app/mtext_editor.rs index 5d6c851c7..ad519605e 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -325,13 +325,14 @@ impl MTextEditorState { 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.column_count = crate::entities::text_support::clamp_mtext_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::() { @@ -347,7 +348,7 @@ impl MTextEditorState { 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.column_data.column_count as usize ]; } } @@ -690,7 +691,10 @@ fn seed_mtext_state(state: &mut MTextEditorState, m: &MText) { 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_count = crate::entities::text_support::clamp_mtext_column_count( + m.column_data.column_count, + ) + .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(); @@ -1073,9 +1077,13 @@ impl super::OpenCADStudio { Cell::Stack { .. } => None, }) .collect(); + if needle.len() > chars.len() { + return; + } 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)) + let last_start = chars.len() - needle.len(); + let found = (start..=last_start) + .chain(0..start.min(last_start + 1)) .find(|index| { chars[*index..*index + needle.len()] .iter() diff --git a/src/app/properties.rs b/src/app/properties.rs index bd7daa3c8..fde96f939 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1799,6 +1799,69 @@ 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 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_at_scale( + text, + doc, + model_factor as f32, + ) + .height as f64 + } else { + text.height + }; + 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 { + 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/app/text_inline.rs b/src/app/text_inline.rs index 5a1100889..995ec7747 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, None); 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,18 @@ 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 command_creation = ed.creation.is_some(); + 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,24 +255,48 @@ impl super::OpenCADStudio { .current_text_style_name .clone(); if !cur_style.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"); - 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, - ); + if t.style.trim().is_empty() { + t.style = cur_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] + .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 _ = self.commit_entity_handle(plane.place_entity(EntityType::Text(t))); self.tabs[i].dirty = true; } self.refresh_properties(); diff --git a/src/app/update/command.rs b/src/app/update/command.rs index d6c48ab69..8b8e2ea01 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/app/update/file.rs b/src/app/update/file.rs index 1f77d4bf7..bbf906eae 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/mod.rs b/src/app/update/mod.rs index 4d9b19944..285fe91a6 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -234,6 +234,25 @@ impl OpenCADStudio { } } + /// Emit `SelectionChangedV4` to V4 plugins when the active tab's selection + /// set actually changed since the last broadcast. + #[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 fingerprint = self.tabs[i].scene.selection_fingerprint(); + let key = (tab_id, fingerprint); + 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); @@ -278,6 +297,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/app/update/viewport.rs b/src/app/update/viewport.rs index a527895b9..34eddd246 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/command.rs b/src/command.rs index 50d17df81..d4a2312b3 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1448,6 +1448,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, @@ -1866,6 +1874,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/mtext.rs b/src/entities/mtext.rs index b8f65ec6e..657dc9780 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -5,7 +5,8 @@ use crate::entities::common::{ edit_angle_prop as edit_angle, edit_prop as edit, num_prop as num_row, ro_prop as ro, square_grip, triangle_grip, }; use crate::entities::text_support::{ - layout_mtext, resolve_text_style, GlyphBox, MTextColumns, MTextRenderOpts, MTextVAnchor, + clamp_mtext_column_count, layout_mtext, resolve_text_style, GlyphBox, MTextColumns, + MTextRenderOpts, MTextVAnchor, }; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject}; @@ -24,13 +25,19 @@ fn columns_of(t: &MText) -> MTextColumns { if c.column_type == 0 { return MTextColumns::default(); } + let count = clamp_mtext_column_count(c.column_count) as usize; MTextColumns { - count: c.column_count.max(0) as usize, + count, 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(), + heights: c + .heights + .iter() + .take(count) + .map(|height| *height as f32) + .collect(), } } @@ -248,12 +255,12 @@ fn grips(t: &MText) -> Vec { t.insertion_point.z, ); let (dir, k) = width_grip_axis(t); - let columns_active = t.column_data.column_type != 0 - && t.column_data.column_count > 1 - && t.column_data.width > 0.0; + let column_count = clamp_mtext_column_count(t.column_data.column_count); + let columns_active = + t.column_data.column_type != 0 && 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 + t.column_data.width * column_count as f64 + + t.column_data.gutter * (column_count - 1) as f64 } else { t.rectangle_width.max(0.0) }; @@ -578,10 +585,12 @@ fn apply_grip(t: &mut MText, grip_id: usize, apply: GripApply) { let dy = p.y as f64 - t.insertion_point.y; let projected = dx * dir.x + dy * dir.y; 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; + let column_count = clamp_mtext_column_count(t.column_data.column_count); + if t.column_data.column_type != 0 && column_count > 1 { + t.column_data.column_count = column_count; + let gaps = (column_count - 1) as f64; t.column_data.width = - ((width - t.column_data.gutter * gaps) / t.column_data.column_count as f64) + ((width - t.column_data.gutter * gaps) / column_count as f64) .max(0.01); } else { t.rectangle_width = width; @@ -606,8 +615,9 @@ fn apply_grip(t: &mut MText, grip_id: usize, apply: GripApply) { 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]; + let column_count = clamp_mtext_column_count(t.column_data.column_count); + t.column_data.column_count = column_count; + t.column_data.heights = vec![height; column_count as usize]; } } (3, GripApply::Absolute(p)) => { diff --git a/src/entities/text.rs b/src/entities/text.rs index 18a96fdad..8e4a504e3 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. @@ -98,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); @@ -140,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( ( @@ -152,10 +197,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 +212,44 @@ 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 / annotation_scale / 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 +259,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 +283,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 +307,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 +318,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,15 +341,25 @@ 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 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. + let is_aligned = matches!(t.horizontal_alignment, HA::Aligned); + let is_two_point = matches!(t.horizontal_alignment, HA::Aligned | HA::Fit); + // 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(), @@ -331,22 +419,31 @@ 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), - 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 { @@ -439,37 +536,64 @@ 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, } } - "height" if v > 0.0 => t.height = v, - "rotation" => t.rotation = v.to_radians(), + "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) => + { + 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; + } } } } @@ -495,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)) } } @@ -551,7 +675,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(); } } diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 0ccd98927..0a59351d1 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -1034,6 +1034,12 @@ pub struct MTextRenderOpts<'a> { /// /// Content moves to the next column at an explicit `\N` break or when the /// configured manual/automatic column height is exhausted. +pub(crate) const MAX_MTEXT_COLUMNS: i32 = 256; + +pub(crate) fn clamp_mtext_column_count(count: i32) -> i32 { + count.clamp(1, MAX_MTEXT_COLUMNS) +} + #[derive(Clone, Debug, Default)] pub struct MTextColumns { /// Column count. 0 or 1 both mean "no column layout". @@ -1065,6 +1071,11 @@ impl MTextColumns { }; physical as f32 * (self.width + self.gutter) } + + fn total_width(&self) -> f32 { + self.width * self.count as f32 + + self.gutter.max(0.0) * self.count.saturating_sub(1) as f32 + } } /// One selectable character in the laid-out text: its world-space AABB plus @@ -1135,6 +1146,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { indent_right: f32, tab_stops: Vec, is_first_in_paragraph: bool, + starts_column: bool, /// Which column this line lives in; always 0 without a column layout. column: usize, /// Extra gap above (paragraph's first wrapped line only) / below (last @@ -1272,6 +1284,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { indent_right: para.indent_right, tab_stops: para.tab_stops.clone(), is_first_in_paragraph: idx == 0, + starts_column: idx == 0 && para.starts_column, column, // The gap above rides the first wrapped line; the gap below the // last, so an interior wrap keeps normal single spacing. @@ -1294,6 +1307,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { indent_right: 0.0, tab_stops: Vec::new(), is_first_in_paragraph: true, + starts_column: false, column: 0, space_before: 0.0, space_after: 0.0, @@ -1401,14 +1415,32 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { // 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 mut pending_after = 0.0_f32; + let total_advance: f32 = sub_lines + .iter() + .enumerate() + .map(|(index, line)| { + if line.starts_column { + pending_after = 0.0; + } + let spacing = if line.is_first_in_paragraph { + line.space_before + pending_after + } else { + 0.0 + }; + pending_after = line.space_after; + per_line_h.get(index).copied().unwrap_or(entity_h) + spacing + }) + .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; + let mut pending_after = 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); + if line.starts_column { + current_column = (current_column + 1).min(cols.count - 1); used = 0.0; + pending_after = 0.0; } let limit = if cols.auto_height { balanced @@ -1419,7 +1451,13 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { .filter(|height| *height > 0.0) .unwrap_or(opts.rectangle_height) }; - let advance = per_line_h.get(index).copied().unwrap_or(entity_h); + let line_advance = per_line_h.get(index).copied().unwrap_or(entity_h); + let spacing = if line.is_first_in_paragraph { + line.space_before + pending_after + } else { + 0.0 + }; + let mut advance = line_advance + spacing; if limit > 0.0 && used > 0.0 && used + advance > limit @@ -1427,9 +1465,16 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { { current_column += 1; used = 0.0; + advance = line_advance + + if line.is_first_in_paragraph { + line.space_before + } else { + 0.0 + }; } line.column = current_column; used += advance; + pending_after = line.space_after; } } // Per-line text height — the tallest Word on the line, an empty line @@ -1499,7 +1544,12 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { MTextVAnchor::BottomOfTopLine => 0.0, }; let attach_h_anchor = opts.attach_h_anchor; - let box_left = -attach_h_anchor * rect_w; + let block_width = if cols.active() { + cols.total_width() + } else { + rect_w + }; + let box_left = -attach_h_anchor * block_width; let rot = opts.rotation; let (cos_r, sin_r) = (rot.cos(), rot.sin()); let ins_x = opts.insertion[0]; @@ -2010,6 +2060,49 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { emit(denominator, valign_dy); emit(numerator, valign_dy + run_h * STACK_RAISE); + if opts.want_glyph_boxes { + let slots = numerator.chars().count() + + denominator.chars().count() + + usize::from(!denominator.is_empty()); + if slots > 0 { + let top = valign_dy + run_h * (STACK_RAISE + STACK_HALF_SCALE); + for index in 0..slots { + let x0 = cursor_x + slot_w * index as f32 / slots as f32; + let x1 = cursor_x + slot_w * (index + 1) as f32 / slots as f32; + let corners = [ + to_world(line_base_x, line_base_y, x0, valign_dy), + to_world(line_base_x, line_base_y, x1, valign_dy), + to_world(line_base_x, line_base_y, x0, top), + to_world(line_base_x, line_base_y, x1, top), + ]; + let xmin = corners + .iter() + .map(|p| p.0) + .fold(f32::INFINITY, f32::min); + let xmax = corners + .iter() + .map(|p| p.0) + .fold(f32::NEG_INFINITY, f32::max); + let ymin = corners + .iter() + .map(|p| p.1) + .fold(f32::INFINITY, f32::min); + let ymax = corners + .iter() + .map(|p| p.1) + .fold(f32::NEG_INFINITY, f32::max); + glyph_boxes.push(GlyphBox { + vis, + xmin, + xmax, + ymin, + ymax, + }); + vis += 1; + } + } + } + if *bar && slot_w > 0.0 { // The rule is plain geometry, not a glyph, so it rides a // run-less group — those keep their strokes, while a @@ -2032,6 +2125,11 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { run: None, }); } + local_bounds[0] = local_bounds[0].min(cursor_x); + local_bounds[1] = local_bounds[1].min(line_ly + valign_dy); + local_bounds[2] = local_bounds[2].max(cursor_x + slot_w); + local_bounds[3] = local_bounds[3] + .max(line_ly + valign_dy + run_h * (STACK_RAISE + STACK_HALF_SCALE)); cursor_x += slot_w; } AtomKind::Space => { diff --git a/src/modules/annotate/mtext.rs b/src/modules/annotate/mtext.rs index 5a1f838a0..6af690398 100644 --- a/src/modules/annotate/mtext.rs +++ b/src/modules/annotate/mtext.rs @@ -104,21 +104,64 @@ impl MTextCommand { true } - fn boundary_size(&self, opposite: DVec3) -> (f64, f64) { + fn boundary_geometry(&self, opposite: DVec3) -> (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 picked_horizontal = local.x * cos + local.y * sin; let vertical = -local.x * sin + local.y * cos; - (self.width.unwrap_or(horizontal.abs()), vertical.abs()) + let width = self.width.unwrap_or(picked_horizontal.abs()); + let horizontal = if self.width.is_some() { + width.copysign(if picked_horizontal.abs() > 1e-12 { + picked_horizontal + } else { + 1.0 + }) + } else { + picked_horizontal + }; + let (xmin, xmax) = (horizontal.min(0.0), horizontal.max(0.0)); + let (ymin, ymax) = (vertical.min(0.0), vertical.max(0.0)); + let horizontal_anchor = match self.attachment { + AttachmentPoint::TopLeft + | AttachmentPoint::MiddleLeft + | AttachmentPoint::BottomLeft => 0.0, + AttachmentPoint::TopCenter + | AttachmentPoint::MiddleCenter + | AttachmentPoint::BottomCenter => 0.5, + AttachmentPoint::TopRight + | AttachmentPoint::MiddleRight + | AttachmentPoint::BottomRight => 1.0, + }; + let vertical_anchor = match self.attachment { + AttachmentPoint::TopLeft + | AttachmentPoint::TopCenter + | AttachmentPoint::TopRight => 1.0, + AttachmentPoint::MiddleLeft + | AttachmentPoint::MiddleCenter + | AttachmentPoint::MiddleRight => 0.5, + AttachmentPoint::BottomLeft + | AttachmentPoint::BottomCenter + | AttachmentPoint::BottomRight => 0.0, + }; + let anchor_x = xmin + (xmax - xmin) * horizontal_anchor; + let anchor_y = ymin + (ymax - ymin) * vertical_anchor; + let offset = DVec3::new( + anchor_x * cos - anchor_y * sin, + anchor_x * sin + anchor_y * cos, + 0.0, + ); + ( + first + self.plane.vector_to_world(offset), + width, + 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 (insertion, width, boundary_height) = self.boundary_geometry(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.insertion_point = Vector3::new(insertion.x, insertion.y, insertion.z); template.height = self.height; template.rectangle_width = width.max(0.0); template.rectangle_height = (boundary_height > 1e-9).then_some(boundary_height); @@ -128,7 +171,8 @@ impl MTextCommand { 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.column_count = + crate::entities::text_support::clamp_mtext_column_count(self.column_count); template.column_data.auto_height = self.column_type == 2; template.column_data.width = if self.column_width > 0.0 { self.column_width @@ -144,7 +188,7 @@ impl MTextCommand { }; } CmdResult::OpenMTextEditor { - pos: first, + pos: insertion, handle: None, initial: String::new(), height: self.height, @@ -295,7 +339,7 @@ impl CadCommand for MTextCommand { } Step::ColumnCount => { let value = trimmed.parse::().ok()?; - if value < 1 { + if !(1..=crate::entities::text_support::MAX_MTEXT_COLUMNS).contains(&value) { return None; } self.column_count = value; diff --git a/src/modules/annotate/text.rs b/src/modules/annotate/text.rs index c00ad9817..90d815a65 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,434 @@ 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, + annotation_multiplier: f64, + last_display_height: Option, + 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, + annotation_multiplier: f64, + ) -> 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, + annotation_multiplier: annotation_multiplier.max(1.0e-9), + last_display_height: None, + 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; + 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.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_display_height = None; + 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 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() { + *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_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 } - 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"] }); diff --git a/src/modules/draw/modify/align.rs b/src/modules/draw/modify/align.rs index ad6122efc..8cd4109c3 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 } } diff --git a/src/modules/draw/modify/offset.rs b/src/modules/draw/modify/offset.rs index d3b5cc0a9..20a988ac6 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, } diff --git a/src/plugin/v4_support.rs b/src/plugin/v4_support.rs index 23b7d2056..59259f0fc 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/cache/properties.rs b/src/scene/cache/properties.rs index d60bbfd6a..fa3c82a07 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -94,7 +94,8 @@ 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)) || matches!(entity, EntityType::MText(_)) { section.props.retain(|prop| prop.field != "handle"); diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index d249f80b1..dfc58a1dc 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 90f7c08df..63f3ea946 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -86,7 +86,8 @@ fn oriented_mtext_corner_groups( annotation_scale: f64, ) -> Vec<[[f64; 2]; 4]> { let columns = &text.column_data; - let count = columns.column_count.max(0) as usize; + let count = crate::entities::text_support::clamp_mtext_column_count(columns.column_count) + as usize; if columns.column_type == 0 || count <= 1 || columns.width <= 0.0 { return vec![oriented_text_corners( verts, @@ -707,7 +708,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 ─────────────── @@ -1803,24 +1811,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/creation_style.rs b/src/scene/creation_style.rs index ed4a1fc53..bfc4ac5ca 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 { diff --git a/src/scene/group_layer.rs b/src/scene/group_layer.rs index af957834e..2c3aa78e3 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 313058bc4..0894fe12c 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. @@ -1865,6 +1868,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), @@ -2618,6 +2623,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; @@ -2998,32 +3008,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 +3109,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 +3117,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 +3167,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 +3180,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 +3198,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) } } @@ -4555,6 +4592,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/paper.rs b/src/scene/paper.rs index db2a4e765..d7a1d9285 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 diff --git a/src/scene/selection.rs b/src/scene/selection.rs index af107d9c1..aa951bdd8 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. @@ -37,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(); } @@ -46,15 +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(); + } + + 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; + } + self.selection_fingerprint_cache } pub(crate) fn selected_handles_in_order(&self) -> Vec { @@ -103,7 +129,7 @@ impl Scene { order.extend(added); self.selected = selected; self.selected_order = order; - self.bump_selection(); + self.bump_selection_set(); } } @@ -118,7 +144,7 @@ impl Scene { } if changed { - self.bump_selection(); + self.bump_selection_set(); } } @@ -199,7 +225,7 @@ impl Scene { } } if added > 0 { - self.bump_selection(); + self.bump_selection_set(); } added } @@ -223,7 +249,9 @@ impl Scene { self.selected_order.push(h); } } - self.bump_selection(); + if self.selected != prev { + self.bump_selection_set(); + } self.selected.len() } @@ -709,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. @@ -724,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); @@ -738,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 @@ -838,3 +869,29 @@ impl Scene { restored } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selection_fingerprint_tracks_final_set_only() { + let mut scene = Scene::default(); + 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(); + 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); + } +}