forked from 1b-agent/opencadstudio-upstream
fix(mtext): harden editor and column layout
Merge current main while preserving text property behavior. Bound column allocations and correct boundary, UCS, caret, and column-flow calculations.
This commit is contained in:
commit
522a3f0ff5
51 files changed
+1894
-530
No files matched your search
@@ -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 <socket> <cdylib>
|
||||
R->>L: unsafe { load(cdylib_path) }
|
||||
L-->>R: Box<dyn BuiltinPlugin>
|
||||
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<HostNotification>`.
|
||||
- `PluginToHostV4`: `Request { id, tab_id, PluginRequest }`, `Response { id, HostResponse }`, `Notification<PluginNotification>`.
|
||||
- `NotificationEnvelope<T>`: `{ command_id: Option<u64>, 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<Handle>`) once per loaded V4 plugin, and `transport::send` re-serializes the payload into a new `Vec<u8>` per plugin. Fixing this properly requires either a public API change (`Arc<Vec<Handle>>` 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)
|
||||
@@ -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)
|
||||
@@ -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 <socket> <cdylib>`) 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 <socket_name> <cdylib_path>`:
|
||||
|
||||
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.
|
||||
@@ -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<Handle> },
|
||||
/// 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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <socket> <cdylib>`), 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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -4,6 +4,18 @@
|
||||
//! (`--ocs-plugin-runner <socket> <cdylib>`). 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;
|
||||
|
||||
@@ -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<T>`] — host-side, file-backed double buffer. Call
|
||||
//! [`DocumentSnapshotStore::publish`] to atomically swap the active segment and
|
||||
//! increment the version.
|
||||
//! - [`SharedDocumentReader<T>`] — 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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -943,7 +943,12 @@ impl OpenCADStudio {
|
||||
|
||||
"ALIGN" => {
|
||||
use crate::modules::draw::modify::align::AlignCommand;
|
||||
let cmd = AlignCommand::new();
|
||||
|
||||
let selected: Vec<acadrust::Handle> =
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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<acadrust::Handle> = 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<acadrust::Handle> = 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;
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// `(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<crate::plugin::external::ExternalPlugin>,
|
||||
@@ -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(),
|
||||
|
||||
+19
-11
@@ -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::<i32>()
|
||||
.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::<i32>()
|
||||
.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::<f64>() {
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+58
-23
@@ -95,6 +95,9 @@ pub struct TextInlineState {
|
||||
pub editing: Option<Handle>,
|
||||
/// 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<Text>,
|
||||
/// 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<Text>,
|
||||
) {
|
||||
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();
|
||||
|
||||
@@ -1162,6 +1162,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
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<Message> {
|
||||
{
|
||||
(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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Message> {
|
||||
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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+23
-13
@@ -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<GripDef> {
|
||||
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)) => {
|
||||
|
||||
+181
-55
@@ -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<GripDef> {
|
||||
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<PropSection> {
|
||||
@@ -263,15 +341,25 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec<PropSection> {
|
||||
// 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<PropSection> {
|
||||
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<RenderEntity> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TabStop>,
|
||||
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 => {
|
||||
|
||||
@@ -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::<i32>().ok()?;
|
||||
if value < 1 {
|
||||
if !(1..=crate::entities::text_support::MAX_MTEXT_COLUMNS).contains(&value) {
|
||||
return None;
|
||||
}
|
||||
self.column_count = value;
|
||||
|
||||
+406
-22
@@ -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<DVec3>,
|
||||
second_point: Option<DVec3>,
|
||||
horizontal: HA,
|
||||
vertical: VA,
|
||||
style_name: String,
|
||||
styles: Vec<TextStyle>,
|
||||
height: f64,
|
||||
rotation: f64,
|
||||
width_factor: f64,
|
||||
oblique_angle: f64,
|
||||
fixed_height: bool,
|
||||
annotative: bool,
|
||||
annotation_multiplier: f64,
|
||||
last_display_height: Option<f64>,
|
||||
last_entity: Option<Text>,
|
||||
}
|
||||
|
||||
impl TextCommand {
|
||||
pub fn with_height(height: f64) -> Self {
|
||||
Self {
|
||||
step: Step::InsertPoint,
|
||||
height,
|
||||
pub fn with_defaults(
|
||||
defaults: TextCreationDefaults,
|
||||
styles: Vec<TextStyle>,
|
||||
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<Text> {
|
||||
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<CmdOption> {
|
||||
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<CmdResult> {
|
||||
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::<f64>().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::<f64>().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<WireModel> {
|
||||
fn on_mouse_move(&mut self, _point: DVec3) -> Option<WireModel> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] }); // TextCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] });
|
||||
@@ -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<Handle>) -> 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<crate::command::CmdOption> {
|
||||
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 <No>.
|
||||
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<WireModel> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<f64> },
|
||||
|
||||
PickSide {
|
||||
/// The object(s) being offset — one from a pick, or the whole
|
||||
/// pre-selection when OFFSET starts with objects selected (#422).
|
||||
targets: Vec<EntityType>,
|
||||
locked: Option<f64>,
|
||||
/// 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<WireModel> {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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<acadrust::Handle>) {
|
||||
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
|
||||
|
||||
Vendored
+2
-1
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
-78
@@ -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<Handle> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+67
-10
@@ -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<Handle> {
|
||||
@@ -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<Handle> = 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user