fix(tolerance): complete workflow integration

Sync current main and companion codec changes before preserving unsupported frame rows, rendering in the entity plane, and sharing final layout metrics with placement preview.
This commit is contained in:
Hakan Seven 2026-08-27 00:14:08 +03:00 committed by GitHub
commit 81e7add1dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 3703 additions and 929 deletions

2
Cargo.lock generated
View file

@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "acadrust"
version = "0.4.1"
source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=9f3cf8e#9f3cf8e26d5a02fb4ad8ea145ca8e04dd6d6f2bc"
source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=34ec2dc46a1d81fb2f41115f5c5f72037dae2572#34ec2dc46a1d81fb2f41115f5c5f72037dae2572"
dependencies = [
"ahash 0.8.12",
"anyhow",

View file

@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] }
rfd = "0.17"
clap = { version = "4", features = ["derive"] }
env_logger = "0.11"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "9f3cf8e", features = ["serde"] }
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "34ec2dc46a1d81fb2f41115f5c5f72037dae2572", features = ["serde"] }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1"
@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa
iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
[patch."https://github.com/HakanSeven12/cadcodec.git"]
acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "9f3cf8e" }
acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "34ec2dc46a1d81fb2f41115f5c5f72037dae2572" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] }

View file

@ -1,150 +1,21 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 48 48"
version="1.1"
id="svg8"
sodipodi:docname="logo.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs8" />
<sodipodi:namedview
id="namedview8"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:zoom="14.27082"
inkscape:cx="10.370813"
inkscape:cy="24.630681"
inkscape:window-width="1920"
inkscape:window-height="939"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg8" />
<!-- Background -->
<rect
width="48"
height="48"
rx="6"
fill="#B03020"
id="rect1" />
<!-- O -->
<line
x1="6.8919725"
y1="9.7197075"
x2="14.891973"
y2="9.7197075"
stroke="#ffffff"
stroke-width="3"
stroke-linecap="round"
id="o-top" />
<line
x1="6.8919725"
y1="37.719707"
x2="14.891973"
y2="37.719707"
stroke="#ffffff"
stroke-width="3"
stroke-linecap="round"
id="o-bot" />
<line
x1="6.8919725"
y1="9.7197075"
x2="6.8919725"
y2="37.719707"
stroke="#ffffff"
stroke-width="3"
stroke-linecap="round"
id="o-left" />
<line
x1="14.891973"
y1="9.7197075"
x2="14.891973"
y2="37.719707"
stroke="#ffffff"
stroke-width="3"
stroke-linecap="round"
id="o-right" />
<!-- C -->
<line
x1="19.891972"
y1="9.7197075"
x2="27.891972"
y2="9.7197075"
stroke="#ffffff"
stroke-width="3"
stroke-linecap="round"
id="c-top" />
<line
x1="19.891972"
y1="37.719707"
x2="27.891972"
y2="37.719707"
stroke="#ffffff"
stroke-width="3"
stroke-linecap="round"
id="c-bot" />
<line
x1="19.891972"
y1="9.7197075"
x2="19.891972"
y2="37.719707"
stroke="#ffffff"
stroke-width="3"
stroke-linecap="round"
id="c-left" />
<!-- S -->
<line
x1="32.891972"
y1="9.7197075"
x2="40.891972"
y2="9.7197075"
stroke="#ffffff"
stroke-width="3"
stroke-linecap="round"
id="s-top" />
<line
x1="32.891972"
y1="9.7197075"
x2="32.891972"
y2="23.719707"
stroke="#ffffff"
stroke-width="3"
stroke-linecap="round"
id="s-tl" />
<line
x1="32.891972"
y1="23.719707"
x2="40.891972"
y2="23.719707"
stroke="#ffffff"
stroke-width="3"
stroke-linecap="round"
id="s-mid" />
<line
x1="40.891972"
y1="23.719707"
x2="40.891972"
y2="37.719707"
stroke="#ffffff"
stroke-width="3"
stroke-linecap="round"
id="s-br" />
<line
x1="32.891972"
y1="37.719707"
x2="40.891972"
y2="37.719707"
stroke="#ffffff"
stroke-width="3"
stroke-linecap="round"
id="s-bot" />
<svg width="920" height="920" viewBox="0 0 920 920" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_12_11)">
<rect width="920" height="920" fill="#1C2430"/>
<path d="M166.001 472.8C149.001 468.7 133.301 455.5 125.001 438.4C118.801 425.7 119.001 428.6 119.001 336.7V252.7L121.801 244.4C129.001 223.7 147.401 207 168.001 202.7C173.301 201.6 182.601 201.2 205.801 201.2H236.501L236.301 228L236.001 254.7L211.001 255.2C183.801 255.7 182.901 255.9 177.301 261.9C171.301 268.1 171.501 266.1 171.501 337.3C171.501 383.2 171.801 403.6 172.601 406.3C174.201 411.5 179.001 417.1 183.901 419.3C187.501 420.9 190.801 421.2 212.201 421.2C225.501 421.2 236.501 421.5 236.701 422C236.901 422.4 236.901 434.3 236.701 448.5L236.301 474.2L203.701 474.1C181.601 474.1 169.401 473.6 166.001 472.8Z" fill="#327DE8"/>
<path d="M250.501 473.8C250.101 473.4 250.001 458.8 250.401 428.5L250.501 421.2H274.501C300.701 421.2 303.201 420.8 308.801 415.4C310.401 413.9 312.701 410.9 313.801 408.7C316.001 404.7 316.001 404.4 316.001 338.2V271.7L313.801 267.5C312.501 265.2 310.301 262.1 308.801 260.6C303.401 255.5 301.501 255.2 275.001 255.2H250.501V228.1V201L284.301 201.4C315.601 201.8 318.501 202 324.501 204C343.501 210.3 359.801 226.8 365.801 245.7C368.001 252.7 368.001 252.8 368.001 338.2V423.7L365.301 431C358.301 450 345.101 463.7 327.001 470.9L320.001 473.7L285.501 474C266.501 474.2 250.701 474.1 250.501 473.8Z" fill="#327DE8"/>
<path d="M631.575 473C631.275 472.2 631.175 460.9 631.275 447.7L631.575 423.7L693.075 423.2C762.275 422.6 758.575 423.1 765.675 414.9C770.475 409.2 771.275 405.6 770.875 391.1C770.575 379.4 770.375 378.3 767.875 374.4C766.375 372.2 762.975 369 760.375 367.5L755.575 364.7L719.075 364.1C690.575 363.6 681.075 363.1 675.775 361.8C652.475 356.3 632.975 337 627.575 314C625.475 305 625.475 261.1 627.575 252.7C632.875 231.7 647.675 215.5 668.875 207.4L675.575 204.8L741.575 204.2C789.775 203.8 807.875 204 808.575 204.8C809.175 205.5 809.575 215.3 809.475 230.3L809.375 254.7L751.475 255.2L693.575 255.7L689.875 258C684.775 261.2 680.375 266.5 679.175 270.9C678.575 273 678.075 279.2 678.075 284.7C678.075 296.1 679.675 300.6 685.175 305.9C691.675 312.1 692.575 312.2 729.275 312.2C753.275 312.3 764.775 312.7 770.375 313.7C790.575 317.3 810.275 332.7 818.475 351.3C823.075 361.8 824.075 369.3 824.075 393.4C824.075 419.2 823.075 425.6 817.275 437.7C809.475 453.9 796.175 465.4 778.075 471.5C771.675 473.7 770.775 473.7 701.775 474C645.075 474.2 631.975 474 631.575 473Z" fill="#F7F7F8"/>
<path d="M450.3 472.1C439 469.1 429.7 463.7 421 455.1C412 446.2 407.2 437.9 403.6 425.6L401 416.5L401.3 336.1C401.7 256.8 401.7 255.5 403.8 249.3C410.3 230 425.1 214.9 444.9 207.2L451.6 204.6L522.4 204.3L593.1 204V229.6V255.1H533.4C497 255.1 472.2 255.5 470 256.1C465.2 257.4 458.2 263.6 456 268.5C454.3 272.4 454.1 276.6 454.1 339.1C454.1 401.6 454.3 405.8 456 409.7C458.3 414.6 464.8 420.4 470 421.9C472.7 422.8 491.1 423.1 533.5 423.1H593.1V448.6V474.1L525.4 474C462 474 457.1 473.9 450.3 472.1Z" fill="#F7F7F8"/>
<rect x="672" y="517" width="12" height="81" fill="white"/>
<rect x="672" y="691" width="12" height="81" fill="white"/>
<rect x="725.5" y="650" width="12" height="93" transform="rotate(-90 725.5 650)" fill="white"/>
<rect x="537.5" y="650" width="12" height="93" transform="rotate(-90 537.5 650)" fill="white"/>
<rect x="649" y="616" width="58" height="57" stroke="#337CE5" stroke-width="18"/>
<rect x="-193" y="1079.79" width="377.615" height="972" transform="rotate(-77.5907 -193 1079.79)" fill="#141D26"/>
<rect x="-450" y="699.014" width="377.615" height="972" transform="rotate(-45 -450 699.014)" fill="#327DE8"/>
</g>
<defs>
<clipPath id="clip0_12_11">
<rect width="920" height="920" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Before After
Before After

View file

@ -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)

View file

@ -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)

View file

@ -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.

View file

@ -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)),
}
}

View file

@ -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};

View file

@ -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;

View file

@ -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};

View file

@ -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};

View file

@ -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);

View file

@ -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;

View file

@ -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};

View file

@ -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};

View file

@ -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);

View file

@ -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 {

View file

@ -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;

View file

@ -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]

View file

@ -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;

View file

@ -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;

View file

@ -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::*;

View file

@ -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;

View file

@ -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` |

View file

@ -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,
);

View file

@ -3716,10 +3716,11 @@ impl OpenCADStudio {
handle,
initial,
height,
template,
} => {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.open_mtext_editor(pos, handle, &initial, height);
self.open_mtext_editor(pos, handle, &initial, height, template.map(|m| *m));
}
CmdResult::SuspendForMTextInput {
pos,
@ -3732,7 +3733,7 @@ impl OpenCADStudio {
self.restore_pre_cmd_tangent();
self.command_mtext_input = true;
self.pending_command_editor_text = None;
self.open_mtext_editor(pos, None, &initial, height);
self.open_mtext_editor(pos, None, &initial, height, None);
}
CmdResult::OpenTextEditor {
pos,
@ -3748,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 } => {

View file

@ -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));
}
@ -1307,7 +1312,13 @@ impl OpenCADStudio {
&self.tabs[i].scene.document,
)
.height;
let new_cmd = MTextCommand::with_height(height);
let style = self.tabs[i]
.scene
.document
.header
.current_text_style_name
.clone();
let new_cmd = MTextCommand::with_defaults(height, style);
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}

View file

@ -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));
}

View file

@ -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;
}

View file

@ -694,6 +694,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>,
@ -2667,6 +2671,31 @@ pub enum Message {
MTextWidth(String),
/// Toolbar character-spacing field changed.
MTextCharSpace(String),
/// Undo / redo editor text and inline-format operations.
MTextUndo,
MTextRedo,
/// Convert the selected numerator/separator/denominator to a stacked run.
MTextStack,
/// Remove inline character formatting from the selection (or all text).
MTextClearFormatting,
/// Insert a predefined symbol or field token at the caret.
MTextInsert(String),
/// Per-object annotation flag edited from the text toolbar.
MTextAnnotative(bool),
/// Column layout controls.
MTextColumnMode(String),
MTextColumnCount(String),
MTextColumnWidth(String),
MTextColumnGutter(String),
MTextColumnHeight(String),
MTextColumnFlowReversed(bool),
/// Paragraph indent/spacing controls.
MTextParagraphNumber(mtext_editor::ParaNumber, String),
MTextFindText(String),
MTextReplaceText(String),
MTextFindNext,
MTextReplaceNext,
MTextReplaceAll,
/// Toolbar colour picker (same widget as Properties) — applies to the
/// selection, or the whole text when nothing is selected.
MTextColorChanged(AcadColor),
@ -3259,6 +3288,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(),

View file

@ -8,7 +8,7 @@
use acadrust::entities::mtext::AttachmentPoint;
use acadrust::entities::mtext_format::{
parse_mtext, MTextColor, MTextDocument, MTextFont, MTextParagraph, MTextParagraphAlignment,
MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType,
MTextScalar, MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType,
};
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle, MText};
@ -38,6 +38,15 @@ pub enum ParaAlign {
Justify,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ParaNumber {
FirstIndent,
LeftIndent,
RightIndent,
SpaceBefore,
SpaceAfter,
}
/// `pick_list`-friendly wrapper for the 9 attachment points.
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct JustifyChoice(pub AttachmentPoint);
@ -107,6 +116,23 @@ pub struct MTextEditorState {
/// it is NOT derived from the typed content, so adding characters wraps
/// to the next line instead of stretching the box into one long line.
pub rect_width: f64,
pub rect_height: String,
pub annotative: bool,
pub column_type: i16,
pub column_count: String,
pub column_auto_height: bool,
pub column_flow_reversed: bool,
pub column_width: String,
pub column_gutter: String,
pub paragraph_first_indent: String,
pub paragraph_left_indent: String,
pub paragraph_right_indent: String,
pub paragraph_space_before: String,
pub paragraph_space_after: String,
pub find_text: String,
pub replace_text: String,
undo: Vec<String>,
redo: Vec<String>,
/// `Some` when editing an existing entity; `None` for a fresh MText.
pub editing: Option<Handle>,
/// When true the panel shows the rendered preview; when false the raw
@ -167,6 +193,23 @@ impl MTextEditorState {
// Default box ~20 characters wide; overwritten with the entity's
// own width when editing an existing MText.
rect_width: (height * 20.0).max(1.0),
rect_height: "0".to_string(),
annotative: false,
column_type: 0,
column_count: "2".to_string(),
column_auto_height: false,
column_flow_reversed: false,
column_width: (height * 10.0).to_string(),
column_gutter: (height * 5.0).to_string(),
paragraph_first_indent: "0".to_string(),
paragraph_left_indent: "0".to_string(),
paragraph_right_indent: "0".to_string(),
paragraph_space_before: "0".to_string(),
paragraph_space_after: "0".to_string(),
find_text: String::new(),
replace_text: String::new(),
undo: Vec::new(),
redo: Vec::new(),
editing,
screen_anchor: iced::Point::new(60.0, 90.0),
original: None,
@ -274,8 +317,54 @@ impl MTextEditorState {
mt.attachment_point = self.attachment;
mt.line_spacing_factor = self.line_spacing as f64;
mt.style = self.style.clone();
mt.is_annotative = self.annotative;
let rectangle_height = self.rect_height.trim().parse::<f64>().unwrap_or(0.0);
mt.rectangle_height = (rectangle_height > 0.0).then_some(rectangle_height);
mt.column_data.column_type = self.column_type;
if self.column_type == 0 {
mt.column_data.column_count = 0;
mt.column_data.heights.clear();
} else {
mt.column_data.column_count = 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>() {
if width > 0.0 {
mt.column_data.width = width;
}
}
if let Ok(gutter) = self.column_gutter.trim().parse::<f64>() {
if gutter >= 0.0 {
mt.column_data.gutter = gutter;
}
}
if !self.column_auto_height && rectangle_height > 0.0 {
mt.column_data.heights = vec![
rectangle_height;
mt.column_data.column_count as usize
];
}
}
mt
}
fn record_undo(&mut self) {
let value = self.content.text();
if self.undo.last().is_none_or(|last| last != &value) {
self.undo.push(value);
if self.undo.len() > 100 {
self.undo.remove(0);
}
}
self.redo.clear();
}
}
/// Parse a numeric field, returning `Some(v)` only when it differs from the
@ -595,6 +684,27 @@ fn vertical_caret_target(
best.1
}
fn seed_mtext_state(state: &mut MTextEditorState, m: &MText) {
state.attachment = m.attachment_point;
state.line_spacing = m.line_spacing_factor as f32;
state.rect_width = m.rectangle_width.max(0.0);
state.rect_height = m.rectangle_height.unwrap_or(0.0).max(0.0).to_string();
state.annotative = m.is_annotative;
state.column_type = m.column_data.column_type;
state.column_count = 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();
state.column_gutter = m.column_data.gutter.max(0.0).to_string();
if !m.style.trim().is_empty() {
state.style = m.style.clone();
}
state.original = Some(m.clone());
}
impl super::OpenCADStudio {
/// Open the in-place editor for a new (`handle = None`) or existing MText.
/// Open the rich MText editor for a new or existing MText / MultiLeader.
@ -605,6 +715,7 @@ impl super::OpenCADStudio {
handle: Option<Handle>,
initial: &str,
height: f64,
template: Option<MText>,
) {
if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) {
return;
@ -614,20 +725,11 @@ impl super::OpenCADStudio {
state.screen_anchor = p;
}
// Seed attachment / line-spacing / box width from the entity being edited.
let has_template = template.is_some();
if let Some(h) = handle {
match self.tabs[self.active_tab].scene.document.get_entity(h) {
Some(EntityType::MText(m)) => {
state.attachment = m.attachment_point;
state.line_spacing = m.line_spacing_factor as f32;
if !m.style.trim().is_empty() {
state.style = m.style.clone();
}
if m.rectangle_width > 0.0 {
state.rect_width = m.rectangle_width;
}
// Keep the whole entity so `build_mtext` preserves the fields
// the toolbar can't edit (columns, rotation, background…).
state.original = Some(m.clone());
seed_mtext_state(&mut state, m);
}
Some(EntityType::MultiLeader(ml)) => {
state.line_spacing = ml.context.line_spacing_factor as f32;
@ -637,6 +739,8 @@ impl super::OpenCADStudio {
}
_ => {}
}
} else if let Some(m) = template {
seed_mtext_state(&mut state, &m);
} else {
// New MText inherits the document's current text style (STYLE),
// not the "Standard" default. See #92.
@ -659,7 +763,7 @@ impl super::OpenCADStudio {
// The preview uses a fixed on-screen text size. Therefore the initial
// wrap width is the drawing-unit span that exactly reaches the right
// edge of the editor, placing both ruler and slider at their maximum.
if handle.is_none() {
if handle.is_none() && !has_template {
let initial_width = self.mtext_editor.as_ref().map(|ed| {
super::view::overlay::MTEXT_EDITOR_WRITING_WIDTH / ed.preview_scale()
});
@ -746,6 +850,322 @@ impl super::OpenCADStudio {
/// Splice text around the preview selection (visible-char range) in the
/// raw value. `case` optionally transforms the selected slice. Returns
/// true when a preview selection was present and applied.
pub(super) fn mtext_undo(&mut self) {
if let Some(ed) = self.mtext_editor.as_mut() {
if let Some(previous) = ed.undo.pop() {
ed.redo.push(ed.content.text());
ed.content = text_editor::Content::with_text(&previous);
ed.caret = previous.chars().count();
ed.sel = Some((ed.caret, ed.caret));
ed.sel_anchor = ed.caret;
}
}
self.rebuild_mtext_preview();
}
pub(super) fn mtext_redo(&mut self) {
if let Some(ed) = self.mtext_editor.as_mut() {
if let Some(next) = ed.redo.pop() {
ed.undo.push(ed.content.text());
ed.content = text_editor::Content::with_text(&next);
ed.caret = next.chars().count();
ed.sel = Some((ed.caret, ed.caret));
ed.sel_anchor = ed.caret;
}
}
self.rebuild_mtext_preview();
}
pub(super) fn mtext_apply_span_number(&mut self, field: &'static str, value: String) {
let Some(number) = value.trim().replace(',', ".").parse::<f64>().ok() else {
return;
};
let valid = match field {
"height" => number > 0.0,
"oblique" => (-85.0..=85.0).contains(&number),
"width" => number > 0.0,
"tracking" => number.is_finite(),
_ => false,
};
if !valid {
return;
}
let has_selection = self
.mtext_editor
.as_ref()
.and_then(|editor| editor.sel)
.is_some_and(|(start, end)| start < end);
if let Some(ed) = self.mtext_editor.as_mut() {
if has_selection {
let (start, end) = ed.sel.unwrap();
ed.record_undo();
let para0 = doc_para0(&ed.doc);
let mut cells = doc_to_cells(&ed.doc);
let end = end.min(cells.len());
for cell in &mut cells[start.min(end)..end] {
if let Cell::Char(_, props) | Cell::Stack { props, .. } = cell {
match field {
"height" => props.height = Some(MTextScalar::Absolute(number)),
"oblique" => props.oblique_angle = (number != 0.0).then_some(number),
"width" => props.width_factor = (number != 1.0).then_some(number),
"tracking" => props.tracking = (number != 0.0).then_some(number),
_ => {}
}
}
}
ed.content = text_editor::Content::with_text(
&cells_to_doc(&para0, &cells).to_mtext_string(),
);
ed.sel = Some((start, end));
ed.caret = end;
} else {
match field {
"height" => ed.height = value,
"oblique" => ed.oblique = value,
"width" => ed.width = value,
"tracking" => ed.char_space = value,
_ => {}
}
}
}
self.rebuild_mtext_preview();
}
pub(super) fn mtext_apply_paragraph_number(&mut self, field: ParaNumber, value: String) {
let Some(number) = value.trim().replace(',', ".").parse::<f64>().ok() else {
return;
};
if !number.is_finite() {
return;
}
if let Some(ed) = self.mtext_editor.as_mut() {
ed.record_undo();
match field {
ParaNumber::FirstIndent => ed.paragraph_first_indent = value,
ParaNumber::LeftIndent => ed.paragraph_left_indent = value,
ParaNumber::RightIndent => ed.paragraph_right_indent = value,
ParaNumber::SpaceBefore => ed.paragraph_space_before = value,
ParaNumber::SpaceAfter => ed.paragraph_space_after = value,
}
let para0 = doc_para0(&ed.doc);
let cells = doc_to_cells(&ed.doc);
let (start, end) = ed
.sel
.filter(|(start, end)| start < end)
.unwrap_or((ed.caret, ed.caret));
let paragraph_of = |index: usize| {
cells
.iter()
.take(index.min(cells.len()))
.filter(|cell| matches!(cell, Cell::Break(..)))
.count()
};
let first = paragraph_of(start);
let last = paragraph_of(end.saturating_sub((end > start) as usize));
let mut doc = cells_to_doc(&para0, &cells);
for paragraph in doc
.paragraphs
.iter_mut()
.skip(first)
.take(last.saturating_sub(first) + 1)
{
let optional = (number != 0.0).then_some(number);
match field {
ParaNumber::FirstIndent => paragraph.properties.first_line_indent = optional,
ParaNumber::LeftIndent => paragraph.properties.left_margin = optional,
ParaNumber::RightIndent => paragraph.properties.right_margin = optional,
ParaNumber::SpaceBefore => paragraph.properties.spacing_before = optional,
ParaNumber::SpaceAfter => paragraph.properties.spacing_after = optional,
}
}
ed.content = text_editor::Content::with_text(&doc.to_mtext_string());
}
self.rebuild_mtext_preview();
}
pub(super) fn mtext_stack_selection(&mut self) {
if let Some(ed) = self.mtext_editor.as_mut() {
let Some((start, end)) = ed.sel.filter(|(start, end)| start < end) else {
return;
};
let para0 = doc_para0(&ed.doc);
let mut cells = doc_to_cells(&ed.doc);
let end = end.min(cells.len());
let plain = cells_to_doc(&ParagraphProperties::default(), &cells[start..end])
.to_plain_text();
let Some((split, separator)) = plain
.char_indices()
.find(|(_, ch)| matches!(ch, '/' | '#' | '^'))
else {
return;
};
let separator_len = separator.len_utf8();
let data = StackingData {
numerator: plain[..split].to_string(),
denominator: plain[split + separator_len..].to_string(),
stacking_type: StackingType::from_char(separator),
};
if data.numerator.is_empty() || data.denominator.is_empty() {
return;
}
let props = cells[start..end]
.iter()
.find_map(|cell| match cell {
Cell::Char(_, props) | Cell::Stack { props, .. } => Some(props.clone()),
Cell::Break(..) => None,
})
.unwrap_or_default();
ed.record_undo();
let replacement = flatten_stack(&data)
.chars()
.enumerate()
.map(|(index, _)| Cell::Stack {
data: data.clone(),
props: props.clone(),
head: index == 0,
})
.collect::<Vec<_>>();
let replacement_len = replacement.len();
cells.splice(start..end, replacement);
let new_end = start + replacement_len;
ed.content = text_editor::Content::with_text(
&cells_to_doc(&para0, &cells).to_mtext_string(),
);
ed.sel = Some((start, new_end));
ed.caret = new_end;
ed.sel_anchor = start;
}
self.rebuild_mtext_preview();
}
pub(super) fn mtext_clear_formatting(&mut self) {
if let Some(ed) = self.mtext_editor.as_mut() {
let para0 = doc_para0(&ed.doc);
let mut cells = doc_to_cells(&ed.doc);
let (start, end) = ed
.sel
.filter(|(start, end)| start < end)
.unwrap_or((0, cells.len()));
let end = end.min(cells.len());
ed.record_undo();
for cell in &mut cells[start.min(end)..end] {
if let Cell::Char(_, props) | Cell::Stack { props, .. } = cell {
*props = SpanProperties::default();
}
}
ed.content = text_editor::Content::with_text(
&cells_to_doc(&para0, &cells).to_mtext_string(),
);
ed.sel = Some((start, end));
ed.caret = end;
}
self.rebuild_mtext_preview();
}
pub(super) fn mtext_find_next(&mut self) {
if let Some(ed) = self.mtext_editor.as_mut() {
let needle: Vec<char> = ed.find_text.chars().collect();
if needle.is_empty() {
return;
}
let cells = doc_to_cells(&ed.doc);
let chars: Vec<Option<char>> = cells
.iter()
.map(|cell| match cell {
Cell::Char(ch, _) => Some(*ch),
Cell::Break(..) => Some('\n'),
Cell::Stack { .. } => None,
})
.collect();
if needle.len() > chars.len() {
return;
}
let start = ed.caret.min(chars.len());
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()
.zip(&needle)
.all(|(actual, expected)| actual == &Some(*expected))
});
if let Some(found) = found {
ed.sel_anchor = found;
ed.sel = Some((found, found + needle.len()));
ed.caret = found + needle.len();
ed.caret_blink_on = true;
}
}
}
pub(super) fn mtext_replace_next(&mut self) {
let replacement = self
.mtext_editor
.as_ref()
.map(|editor| editor.replace_text.clone())
.unwrap_or_default();
let matches = self.mtext_editor.as_ref().is_some_and(|editor| {
let Some((start, end)) = editor.sel.filter(|(start, end)| start < end) else {
return false;
};
let cells = doc_to_cells(&editor.doc);
cells_to_doc(
&ParagraphProperties::default(),
&cells[start.min(cells.len())..end.min(cells.len())],
)
.to_plain_text()
== editor.find_text
});
if matches {
self.mtext_type(&replacement);
}
self.mtext_find_next();
}
pub(super) fn mtext_replace_all(&mut self) {
if let Some(ed) = self.mtext_editor.as_mut() {
let needle: Vec<char> = ed.find_text.chars().collect();
if needle.is_empty() {
return;
}
let para0 = doc_para0(&ed.doc);
let mut cells = doc_to_cells(&ed.doc);
let replacement = ed.replace_text.clone();
let mut matches = Vec::new();
let mut index = 0usize;
while index + needle.len() <= cells.len() {
let found = cells[index..index + needle.len()]
.iter()
.zip(&needle)
.all(|(cell, expected)| matches!(cell, Cell::Char(actual, _) if actual == expected));
if found {
matches.push(index);
index += needle.len();
} else {
index += 1;
}
}
if matches.is_empty() {
return;
}
ed.record_undo();
for start in matches.into_iter().rev() {
let props = insert_props(&cells, start);
let paragraph = para_props_at(&para0, &cells, start);
let replacement_cells = str_to_cells(&replacement, &props, &paragraph);
cells.splice(start..start + needle.len(), replacement_cells);
}
ed.content = text_editor::Content::with_text(
&cells_to_doc(&para0, &cells).to_mtext_string(),
);
ed.caret = cells.len();
ed.sel = Some((ed.caret, ed.caret));
ed.sel_anchor = ed.caret;
}
self.rebuild_mtext_preview();
}
/// Toggle a character format over the preview selection, on the structured
/// span properties. The stroke-font renderer has no true bold, so Bold
/// switches the run to the heavier "Gothic" face; Italic applies a 15°
@ -798,6 +1218,7 @@ impl super::OpenCADStudio {
if a >= b {
return;
}
ed.record_undo();
match kind {
MTextFmt::Underline => {
let on = !all_have(&cells[a..b], |p| p.underline());
@ -882,6 +1303,7 @@ impl super::OpenCADStudio {
/// Set the alignment of every paragraph the selection (or caret) touches.
pub(super) fn mtext_apply_align(&mut self, align: ParaAlign) {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.record_undo();
let para0 = doc_para0(&ed.doc);
let cells = doc_to_cells(&ed.doc);
let (a, b) = ed
@ -933,6 +1355,7 @@ impl super::OpenCADStudio {
s
};
if let Some(ed) = self.mtext_editor.as_mut() {
ed.record_undo();
// Edit the structured document as a flat cell list, then serialize
// it back into the raw content the preview/commit still read.
let para0 = doc_para0(&ed.doc);
@ -962,6 +1385,7 @@ impl super::OpenCADStudio {
/// Delete the selection, or the visible character before the caret.
pub(super) fn mtext_backspace(&mut self) {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.record_undo();
let para0 = doc_para0(&ed.doc);
let mut cells = doc_to_cells(&ed.doc);
let count = cells.len();
@ -985,6 +1409,7 @@ impl super::OpenCADStudio {
/// Delete the selection, or the visible character at the caret.
pub(super) fn mtext_delete(&mut self) {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.record_undo();
let para0 = doc_para0(&ed.doc);
let mut cells = doc_to_cells(&ed.doc);
let count = cells.len();
@ -1104,6 +1529,7 @@ impl super::OpenCADStudio {
let mut cells = doc_to_cells(&ed.doc);
let b = b.min(cells.len());
if a < b {
ed.record_undo();
for c in &mut cells[a..b] {
if let Cell::Char(_, p) | Cell::Stack { props: p, .. } = c {
let (bold, italic) = p
@ -1155,6 +1581,7 @@ impl super::OpenCADStudio {
let mut cells = doc_to_cells(&ed.doc);
let b = b.min(cells.len());
if a < b {
ed.record_undo();
for c in &mut cells[a..b] {
if let Cell::Char(_, p) | Cell::Stack { props: p, .. } = c {
p.color = mcolor.clone();
@ -1217,7 +1644,8 @@ impl super::OpenCADStudio {
}
let body_empty = ed.content.text().trim().is_empty();
let mut mt = ed.build_mtext();
let annotative = ed.editing.is_none()
let annotative = mt.is_annotative
|| ed.editing.is_none()
&& crate::scene::annotative::text_style_is_annotative(
&self.tabs[i].scene.document,
&mt.style,
@ -1240,9 +1668,12 @@ impl super::OpenCADStudio {
Some(EntityType::MText(t)) => {
t.value = mt.value;
t.height = mt.height;
t.style = mt.style;
t.attachment_point = mt.attachment_point;
t.line_spacing_factor = mt.line_spacing_factor;
t.rectangle_width = mt.rectangle_width;
t.rectangle_height = mt.rectangle_height;
t.column_data = mt.column_data;
}
Some(EntityType::MultiLeader(ml)) => {
ml.context.text_string = mt.value;
@ -1255,9 +1686,25 @@ impl super::OpenCADStudio {
_ => {}
}
// Keep the displayed annotation context in sync.
if matches!(self.tabs[i].scene.document.get_entity(h), Some(EntityType::MText(_))) {
crate::scene::annotative::set_entity_annotative(
&mut self.tabs[i].scene.document,
h,
annotative,
);
if annotative {
if let Some(scale) = self.tabs[i].scene.current_annotation_scale_handle() {
crate::scene::annotative::create_annotation_context(
&mut self.tabs[i].scene.document,
h,
scale,
);
}
}
}
if matches!(
self.tabs[i].scene.document.get_entity(h),
Some(EntityType::MultiLeader(_))
Some(EntityType::MText(_) | EntityType::MultiLeader(_))
) {
self.tabs[i]
.scene
@ -1283,7 +1730,6 @@ impl super::OpenCADStudio {
position.y,
position.z,
);
mt.rotation = 0.0;
self.push_undo_snapshot(i, "MTEXT");
let handle = self.commit_entity_handle(plane.place_entity(EntityType::MText(mt)));
if annotative {
@ -1321,7 +1767,8 @@ impl super::OpenCADStudio {
),
None => return,
};
let annotative = editing.is_none()
let annotative = mt.is_annotative
|| editing.is_none()
&& crate::scene::annotative::text_style_is_annotative(
&self.tabs[i].scene.document,
&mt.style,
@ -1341,9 +1788,12 @@ impl super::OpenCADStudio {
Some(EntityType::MText(t)) => {
t.value = mt.value;
t.height = mt.height;
t.style = mt.style;
t.attachment_point = mt.attachment_point;
t.line_spacing_factor = mt.line_spacing_factor;
t.rectangle_width = mt.rectangle_width;
t.rectangle_height = mt.rectangle_height;
t.column_data = mt.column_data;
}
Some(EntityType::MultiLeader(ml)) => {
ml.context.text_string = mt.value;
@ -1355,9 +1805,25 @@ impl super::OpenCADStudio {
}
_ => {}
}
if matches!(self.tabs[i].scene.document.get_entity(h), Some(EntityType::MText(_))) {
crate::scene::annotative::set_entity_annotative(
&mut self.tabs[i].scene.document,
h,
annotative,
);
if annotative {
if let Some(scale) = self.tabs[i].scene.current_annotation_scale_handle() {
crate::scene::annotative::create_annotation_context(
&mut self.tabs[i].scene.document,
h,
scale,
);
}
}
}
if matches!(
self.tabs[i].scene.document.get_entity(h),
Some(EntityType::MultiLeader(_))
Some(EntityType::MText(_) | EntityType::MultiLeader(_))
) {
self.tabs[i]
.scene
@ -1383,7 +1849,6 @@ impl super::OpenCADStudio {
position.y,
position.z,
);
mt.rotation = 0.0;
self.push_undo_snapshot(i, "MTEXT");
let handle = self.commit_entity_handle(plane.place_entity(EntityType::MText(mt)));
self.tabs[i].dirty = true;
@ -1400,6 +1865,11 @@ impl super::OpenCADStudio {
// Bind the editor to the fresh entity so the next Apply updates it.
if let (Some(h), Some(ed)) = (handle, self.mtext_editor.as_mut()) {
ed.editing = Some(h);
if let Some(EntityType::MText(mtext)) =
self.tabs[i].scene.document.get_entity(h)
{
ed.original = Some(mtext.clone());
}
}
}
self.refresh_properties();

View file

@ -1884,6 +1884,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() {

View file

@ -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,
}
@ -173,10 +176,10 @@ impl super::OpenCADStudio {
};
if field.is_rich() {
self.open_mtext_editor(pos, Some(target), &value, height);
self.open_mtext_editor(pos, Some(target), &value, height, None);
self.unfocus_widgets()
} else {
self.open_text_inline(pos, Some(target), &value, height, field);
self.open_text_inline(pos, Some(target), &value, height, field, None);
iced::widget::operation::focus(iced::widget::Id::new(super::view::TEXT_INLINE_ID))
}
}
@ -190,6 +193,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;
@ -200,6 +204,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 {
@ -236,12 +241,18 @@ impl super::OpenCADStudio {
} else {
crate::command::WorkingPlane::default()
};
let command_creation = ed.creation.is_some();
let mut t = if let Some(mut prepared) = ed.creation {
prepared.value = ed.value.clone();
prepared
} else {
let position = plane.to_local(ed.pos);
let mut t = Text::with_value(
Text::with_value(
&ed.value,
Vector3::new(position.x, position.y, position.z),
)
.with_height(ed.height);
.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]
@ -251,24 +262,48 @@ impl super::OpenCADStudio {
.current_text_style_name
.clone();
if !cur_style.is_empty() {
if t.style.trim().is_empty() {
t.style = cur_style;
}
let annotative = crate::scene::annotative::text_style_is_annotative(
}
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");
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,
);
}
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();

View file

@ -71,8 +71,28 @@ impl super::OpenCADStudio {
return false;
}
let i = self.active_tab;
let mut command =
crate::modules::annotate::tolerance_cmd::ToleranceCommand::with_text(state.to_text());
let text = state.to_text();
let mut preview_entity = EntityType::Tolerance(
acadrust::entities::Tolerance::with_text(
acadrust::types::Vector3::ZERO,
text.clone(),
),
);
crate::scene::creation_style::apply_current_creation_styles(
&self.tabs[i].scene.document,
&mut preview_entity,
);
let preview_strokes = match &preview_entity {
EntityType::Tolerance(tolerance) => crate::entities::tolerance::preview_strokes(
tolerance,
&self.tabs[i].scene.document,
),
_ => Vec::new(),
};
let mut command = crate::modules::annotate::tolerance_cmd::ToleranceCommand::with_text(
text,
preview_strokes,
);
use crate::command::CadCommand;
let plane = if self.tabs[i].editing_model_space() {
self.tabs[i].ucs_xform().working_plane()

View file

@ -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,

View file

@ -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);

View file

@ -235,6 +235,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);
@ -279,6 +298,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).
@ -3915,10 +3939,7 @@ impl OpenCADStudio {
Task::none()
}
Message::MTextHeight(s) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.height = s;
}
self.rebuild_mtext_preview();
self.mtext_apply_span_number("height", s);
Task::none()
}
Message::MTextRectWidth(width) => {
@ -3950,26 +3971,128 @@ impl OpenCADStudio {
Task::none()
}
Message::MTextOblique(s) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.oblique = s;
}
self.rebuild_mtext_preview();
self.mtext_apply_span_number("oblique", s);
Task::none()
}
Message::MTextWidth(s) => {
self.mtext_apply_span_number("width", s);
Task::none()
}
Message::MTextCharSpace(s) => {
self.mtext_apply_span_number("tracking", s);
Task::none()
}
Message::MTextUndo => {
self.mtext_undo();
Task::none()
}
Message::MTextRedo => {
self.mtext_redo();
Task::none()
}
Message::MTextStack => {
self.mtext_stack_selection();
Task::none()
}
Message::MTextClearFormatting => {
self.mtext_clear_formatting();
Task::none()
}
Message::MTextInsert(value) => {
self.mtext_type(&value);
Task::none()
}
Message::MTextAnnotative(value) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.width = s;
ed.annotative = value;
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextCharSpace(s) => {
Message::MTextColumnMode(mode) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.char_space = s;
match mode.as_str() {
"Static" => {
ed.column_type = 1;
ed.column_auto_height = false;
}
"Dynamic auto" => {
ed.column_type = 2;
ed.column_auto_height = true;
}
"Dynamic manual" => {
ed.column_type = 2;
ed.column_auto_height = false;
}
_ => ed.column_type = 0,
}
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextColumnCount(value) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.column_count = value;
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextColumnWidth(value) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.column_width = value;
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextColumnGutter(value) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.column_gutter = value;
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextColumnHeight(value) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.rect_height = value;
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextColumnFlowReversed(value) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.column_flow_reversed = value;
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextParagraphNumber(field, value) => {
self.mtext_apply_paragraph_number(field, value);
Task::none()
}
Message::MTextFindText(value) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.find_text = value;
}
Task::none()
}
Message::MTextReplaceText(value) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.replace_text = value;
}
Task::none()
}
Message::MTextFindNext => {
self.mtext_find_next();
Task::none()
}
Message::MTextReplaceNext => {
self.mtext_replace_next();
Task::none()
}
Message::MTextReplaceAll => {
self.mtext_replace_all();
Task::none()
}
Message::MTextJustify(ap) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.attachment = ap;

View file

@ -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

View file

@ -6,7 +6,8 @@ use iced::advanced::renderer;
use iced::advanced::widget;
use iced::advanced::{Layout, Shell, Widget};
use iced::widget::{
button, column, container, mouse_area, row, scrollable, stack, text, text_input, Space,
button, checkbox, column, container, mouse_area, row, scrollable, stack, text, text_input,
Space,
};
use iced::{Background, Border, Color, Element, Event, Fill, Length, Rectangle, Size, Theme, Vector};
use crate::t;
@ -374,7 +375,7 @@ pub(super) fn mtext_editor_overlay<'a>(
iced::widget::Space::new().width(Fill).height(Fill),
t!("Text Editor"),
content,
Message::MTextCancel,
Message::MTextOk,
modal_offset,
crate::ui::modal::ModalOptions::STANDARD,
)
@ -450,6 +451,9 @@ fn mtext_editor_content<'a>(
.on_select(Message::MTextStyle)
.text_size(11)
.width(iced::Length::Fixed(96.0));
let annotative = checkbox(ed.annotative)
.on_toggle(Message::MTextAnnotative)
.size(14);
let font_sel = if ed.font.trim().is_empty() {
"[Style default]".to_string()
} else {
@ -487,6 +491,9 @@ fn mtext_editor_content<'a>(
let row1 = row![
style_pl,
row![annotative, text(t!("Annotative")).size(11)]
.spacing(3)
.align_y(iced::Alignment::Center),
font_pl,
small_input("2.5", &ed.height, Message::MTextHeight, 64.0),
iced::widget::Space::new().width(6),
@ -535,6 +542,14 @@ fn mtext_editor_content<'a>(
.text_size(11)
.width(iced::Length::Fixed(112.0));
let row2 = row![
button(lbl(""))
.on_press(Message::MTextUndo)
.padding(3)
.style(btn_style),
button(lbl(""))
.on_press(Message::MTextRedo)
.padding(3)
.style(btn_style),
lbl("O"),
small_input("0", &ed.oblique, Message::MTextOblique, 48.0),
lbl("W"),
@ -573,6 +588,144 @@ fn mtext_editor_content<'a>(
.on_press(Message::MTextLineSpacing(2.0))
.padding(3)
.style(btn_style),
button(lbl("Stack"))
.on_press(Message::MTextStack)
.padding(3)
.style(btn_style),
button(lbl("Clear"))
.on_press(Message::MTextClearFormatting)
.padding(3)
.style(btn_style),
button(lbl("°"))
.on_press(Message::MTextInsert("°".to_string()))
.padding(3)
.style(btn_style),
button(lbl("±"))
.on_press(Message::MTextInsert("±".to_string()))
.padding(3)
.style(btn_style),
button(lbl(""))
.on_press(Message::MTextInsert("".to_string()))
.padding(3)
.style(btn_style),
]
.spacing(4)
.align_y(iced::Alignment::Center)
.width(width);
let column_mode = match (ed.column_type, ed.column_auto_height) {
(1, _) => "Static",
(2, true) => "Dynamic auto",
(2, false) => "Dynamic manual",
_ => "No columns",
}
.to_string();
let column_picker = iced::widget::pick_list(
Some(column_mode),
["No columns", "Static", "Dynamic auto", "Dynamic manual"]
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>(),
|value| value.to_string(),
)
.on_select(Message::MTextColumnMode)
.text_size(11)
.width(iced::Length::Fixed(120.0));
let reverse = checkbox(ed.column_flow_reversed)
.on_toggle(Message::MTextColumnFlowReversed)
.size(14);
let row3 = row![
lbl("Columns"),
column_picker,
lbl("Count"),
small_input("2", &ed.column_count, Message::MTextColumnCount, 42.0),
lbl("Height"),
text_input("0", &ed.rect_height)
.on_input(Message::MTextColumnHeight)
.width(iced::Length::Fixed(58.0))
.padding(3)
.size(12),
lbl("Width"),
small_input("0", &ed.column_width, Message::MTextColumnWidth, 58.0),
lbl("Gutter"),
small_input("0", &ed.column_gutter, Message::MTextColumnGutter, 58.0),
reverse,
lbl("Reverse"),
iced::widget::Space::new().width(8),
lbl("First"),
text_input("0", &ed.paragraph_first_indent)
.on_input(|value| Message::MTextParagraphNumber(
super::super::mtext_editor::ParaNumber::FirstIndent,
value,
))
.width(iced::Length::Fixed(48.0))
.padding(3)
.size(12),
lbl("Left"),
text_input("0", &ed.paragraph_left_indent)
.on_input(|value| Message::MTextParagraphNumber(
super::super::mtext_editor::ParaNumber::LeftIndent,
value,
))
.width(iced::Length::Fixed(48.0))
.padding(3)
.size(12),
lbl("Right"),
text_input("0", &ed.paragraph_right_indent)
.on_input(|value| Message::MTextParagraphNumber(
super::super::mtext_editor::ParaNumber::RightIndent,
value,
))
.width(iced::Length::Fixed(48.0))
.padding(3)
.size(12),
lbl("Before"),
text_input("0", &ed.paragraph_space_before)
.on_input(|value| Message::MTextParagraphNumber(
super::super::mtext_editor::ParaNumber::SpaceBefore,
value,
))
.width(iced::Length::Fixed(48.0))
.padding(3)
.size(12),
lbl("After"),
text_input("0", &ed.paragraph_space_after)
.on_input(|value| Message::MTextParagraphNumber(
super::super::mtext_editor::ParaNumber::SpaceAfter,
value,
))
.width(iced::Length::Fixed(48.0))
.padding(3)
.size(12),
]
.spacing(4)
.align_y(iced::Alignment::Center)
.width(width);
let row4 = row![
lbl("Find"),
text_input("Find", &ed.find_text)
.on_input(Message::MTextFindText)
.width(iced::Length::Fixed(140.0))
.padding(3)
.size(12),
button(lbl("Next"))
.on_press(Message::MTextFindNext)
.padding(3)
.style(btn_style),
lbl("Replace"),
text_input("Replace", &ed.replace_text)
.on_input(Message::MTextReplaceText)
.width(iced::Length::Fixed(140.0))
.padding(3)
.size(12),
button(lbl("Replace"))
.on_press(Message::MTextReplaceNext)
.padding(3)
.style(btn_style),
button(lbl("Replace All"))
.on_press(Message::MTextReplaceAll)
.padding(3)
.style(btn_style),
]
.spacing(4)
.align_y(iced::Alignment::Center)
@ -710,13 +863,13 @@ fn mtext_editor_content<'a>(
.into()
};
// ── Top action bar: Apply on the right, exactly like the style managers'
// toolbar strip. Closing (the modal ✕) without applying discards the
// buffer, so there is no separate Cancel.
// ── Top action bar: Apply keeps editing; Close saves and exits. Escape
// remains the explicit discard path.
let action_bar = container(
row![
iced::widget::Space::new().width(width),
crate::ui::style::style_manager::tb_button(t!("Apply"), Message::MTextApply, true),
crate::ui::style::style_manager::tb_button(t!("Close Text Editor"), Message::MTextOk, true),
]
.align_y(iced::Alignment::Center),
)
@ -730,7 +883,7 @@ fn mtext_editor_content<'a>(
.padding([5, 8]);
container(
column![action_bar, row1, row2, width_slider, body]
column![action_bar, row1, row2, row3, row4, width_slider, body]
.spacing(6)
.width(width)
.height(height),

View file

@ -1427,6 +1427,11 @@ pub enum CmdResult {
handle: Option<Handle>,
initial: String,
height: f64,
/// Optional entity defaults collected by the interactive MTEXT
/// command (boundary, rotation, attachment, spacing and columns).
/// Existing-entity edits leave this as `None` and load the document
/// entity instead.
template: Option<Box<acadrust::MText>>,
},
/// Collect rich text without creating an MText entity.
SuspendForMTextInput {
@ -1443,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,
@ -1861,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 {

View file

@ -200,6 +200,7 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument
oblique_angle,
is_backward: width_factor < 0.0,
is_upside_down: false,
is_vertical: false,
};
let layout = layout_mtext(&MTextRenderOpts {
// Not an MTEXT: text in a fixed box, never columnar.
@ -213,6 +214,8 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument
attach_h_anchor,
v_anchor,
line_spacing_factor: 1.0,
exact_line_spacing: false,
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: false,
});
@ -304,6 +307,7 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument
origin,
color: None,
fill_tris,
plane: None,
// Carry a GlyphRun so the attribute renders as SDF glyph quads
// (same as TEXT); the strokes above are the historical fallback and
// are suppressed by the per-group SDF path.

View file

@ -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,10 +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()
.take(count)
.map(|height| *height as f32)
.collect(),
}
}
@ -120,7 +130,14 @@ pub fn glyph_boxes(t: &MText, document: &acadrust::CadDocument) -> Vec<GlyphBox>
attach_h_anchor,
v_anchor,
line_spacing_factor: t.line_spacing_factor as f32,
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom),
exact_line_spacing: matches!(
t.line_spacing_style,
acadrust::entities::LineSpacingStyle::Exactly
),
rectangle_height: t.rectangle_height.unwrap_or(0.0) as f32,
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom)
|| matches!(t.drawing_direction, DrawingDirection::ByStyle)
&& resolved_style.is_vertical,
want_glyph_boxes: true,
columns: columns_of(t),
});
@ -169,7 +186,14 @@ fn to_render(t: &MText, document: &acadrust::CadDocument) -> RenderEntity {
attach_h_anchor,
v_anchor,
line_spacing_factor: t.line_spacing_factor as f32,
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom),
exact_line_spacing: matches!(
t.line_spacing_style,
acadrust::entities::LineSpacingStyle::Exactly
),
rectangle_height: t.rectangle_height.unwrap_or(0.0) as f32,
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom)
|| matches!(t.drawing_direction, DrawingDirection::ByStyle)
&& resolved_style.is_vertical,
want_glyph_boxes: false,
columns: columns_of(t),
});
@ -231,8 +255,43 @@ fn grips(t: &MText) -> Vec<GripDef> {
t.insertion_point.z,
);
let (dir, k) = width_grip_axis(t);
let width_grip = p + dir * (k * t.rectangle_width.max(0.0));
vec![square_grip(0, p), triangle_grip(1, width_grip)]
let 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 * column_count as f64
+ t.column_data.gutter * (column_count - 1) as f64
} else {
t.rectangle_width.max(0.0)
};
let width_grip = p + dir * (k * block_width);
let mut result = vec![square_grip(0, p), triangle_grip(1, width_grip)];
let height = t
.rectangle_height
.or_else(|| t.column_data.heights.first().copied())
.unwrap_or(0.0);
if height > 0.0 && !(columns_active && t.column_data.auto_height) {
let (sin, cos) = t.rotation.sin_cos();
let down = glam::DVec3::new(sin, -cos, 0.0);
let (_, vertical) = attach_anchors(t);
let factor = match vertical {
MTextVAnchor::Top
| MTextVAnchor::MiddleOfTopLine
| MTextVAnchor::BottomOfTopLine => 1.0,
MTextVAnchor::Middle => 0.5,
MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => -1.0,
};
result.push(triangle_grip(2, p + down * (factor * height)));
}
if columns_active {
result.push(triangle_grip(3, p + dir * (k * t.column_data.width)));
result.push(triangle_grip(
4,
p + dir * (k * (t.column_data.width + t.column_data.gutter)),
));
}
result
}
fn columns_str(c: &acadrust::entities::MTextColumnData) -> &'static str {
@ -248,10 +307,9 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec<PropSection> {
// by the line-spacing factor.
let line_space_distance = t.height * 1.666_666_666_666_667 * t.line_spacing_factor;
let text_frame_on = (t.background_fill_flags & 0x10) != 0;
// Defined width is only live without columns; defined height is live for
// static columns or manual-height dynamic columns, grayed otherwise.
// Defined width is reported by Properties but is not edited there. Defined
// height remains live for static columns or manual-height dynamic columns.
let col_type = t.column_data.column_type;
let width_editable = col_type == 0;
let height_editable = col_type == 1 || (col_type == 2 && !t.column_data.auto_height);
vec![
PropSection {
@ -353,7 +411,7 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec<PropSection> {
.collect(),
},
},
num_row(t!("Defined width").as_ref(), "rect_w", t.rectangle_width, width_editable),
num_row(t!("Defined width").as_ref(), "rect_w", t.rectangle_width, false),
num_row(
t!("Defined height").as_ref(),
"rect_h",
@ -371,20 +429,6 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec<PropSection> {
.collect(),
},
},
// Count / width / gutter are live only when columns are on.
num_row(
t!("Column count").as_ref(),
"col_count",
t.column_data.column_count as f64,
col_type != 0,
),
num_row(t!("Column width").as_ref(), "col_width", t.column_data.width, col_type != 0),
num_row(
t!("Column gutter").as_ref(),
"col_gutter",
t.column_data.gutter,
col_type != 0,
),
Property {
label: t!("Text frame").into_owned(),
field: "text_frame",
@ -502,21 +546,20 @@ fn apply_geom_prop(t: &mut MText, field: &str, value: &str) {
"ins_y" => t.insertion_point.y = v,
"ins_z" => t.insertion_point.z = v,
"height" if v > 0.0 => t.height = v,
"rect_w" if v > 0.0 => t.rectangle_width = v,
"rect_h" if v > 0.0 => t.rectangle_height = Some(v),
"rect_h" if v >= 0.0 => t.rectangle_height = (v > 0.0).then_some(v),
"rotation" => t.rotation = v.to_radians(),
"line_spacing" if v > 0.0 => t.line_spacing_factor = v,
"line_spacing" if (0.25..=4.0).contains(&v) => t.line_spacing_factor = v,
// Editing the absolute distance back-solves the line-spacing factor so
// the two stay consistent (distance = height × 5/3 × factor).
"line_space_distance" if v > 0.0 => {
let denom = t.height * 1.666_666_666_666_667;
if denom > 0.0 {
t.line_spacing_factor = v / denom;
let factor = v / denom;
if (0.25..=4.0).contains(&factor) {
t.line_spacing_factor = factor;
}
}
}
"col_count" if v >= 1.0 => t.column_data.column_count = v.round() as i32,
"col_width" if v > 0.0 => t.column_data.width = v,
"col_gutter" if v >= 0.0 => t.column_data.gutter = v,
_ => {}
}
}
@ -541,7 +584,60 @@ fn apply_grip(t: &mut MText, grip_id: usize, apply: GripApply) {
let dx = p.x as f64 - t.insertion_point.x;
let dy = p.y as f64 - t.insertion_point.y;
let projected = dx * dir.x + dy * dir.y;
t.rectangle_width = (projected / k).max(0.01);
let width = (projected / k).max(0.0);
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) / column_count as f64)
.max(0.01);
} else {
t.rectangle_width = width;
}
}
(2, GripApply::Absolute(p)) => {
let (sin, cos) = t.rotation.sin_cos();
let down = glam::DVec3::new(sin, -cos, 0.0);
let delta = glam::DVec3::new(
p.x as f64 - t.insertion_point.x,
p.y as f64 - t.insertion_point.y,
p.z as f64 - t.insertion_point.z,
);
let (_, vertical) = attach_anchors(t);
let factor = match vertical {
MTextVAnchor::Top
| MTextVAnchor::MiddleOfTopLine
| MTextVAnchor::BottomOfTopLine => 1.0,
MTextVAnchor::Middle => 0.5,
MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => -1.0,
};
let height = (delta.dot(down) / factor).max(0.01);
t.rectangle_height = Some(height);
if t.column_data.column_type != 0 && !t.column_data.auto_height {
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)) => {
let (dir, k) = width_grip_axis(t);
let delta = glam::DVec3::new(
p.x as f64 - t.insertion_point.x,
p.y as f64 - t.insertion_point.y,
0.0,
);
t.column_data.width = (delta.dot(dir) / k).max(0.01);
}
(4, GripApply::Absolute(p)) => {
let (dir, k) = width_grip_axis(t);
let delta = glam::DVec3::new(
p.x as f64 - t.insertion_point.x,
p.y as f64 - t.insertion_point.y,
0.0,
);
t.column_data.gutter =
(delta.dot(dir) / k - t.column_data.width).max(0.0);
}
_ => {}
}

View file

@ -195,6 +195,11 @@ fn to_render(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<Rende
},
),
line_spacing_factor: ctx.line_spacing_factor as f32,
exact_line_spacing: matches!(
ctx.line_spacing_style,
acadrust::entities::LineSpacingStyle::Exactly
),
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: false,
}))
@ -412,6 +417,7 @@ fn text_box_geom(ml: &MultiLeader) -> ([f64; 2], [f64; 3]) {
oblique_angle: 0.0,
is_backward: false,
is_upside_down: false,
is_vertical: false,
};
let layout = layout_mtext(&MTextRenderOpts {
columns: Default::default(),
@ -424,6 +430,11 @@ fn text_box_geom(ml: &MultiLeader) -> ([f64; 2], [f64; 3]) {
attach_h_anchor: 0.0,
v_anchor: MTextVAnchor::Top,
line_spacing_factor: ctx.line_spacing_factor as f32,
exact_line_spacing: matches!(
ctx.line_spacing_style,
acadrust::entities::LineSpacingStyle::Exactly
),
rectangle_height: 0.0,
vertical_text: vertical,
want_glyph_boxes: false,
});
@ -2039,6 +2050,11 @@ impl MultiLeaderTess for MultiLeader {
attach_h_anchor: h_anchor,
v_anchor,
line_spacing_factor: ctx.line_spacing_factor as f32,
exact_line_spacing: matches!(
ctx.line_spacing_style,
acadrust::entities::LineSpacingStyle::Exactly
),
rectangle_height: 0.0,
// Vertical flow (top-to-bottom) is stored per-context.
vertical_text: matches!(
ctx.text_flow_direction,

View file

@ -180,6 +180,7 @@ impl RenderConvertible for RasterImage {
origin: ins,
color: None,
fill_tris: vec![],
plane: None,
run: None,
}];
@ -218,6 +219,7 @@ impl RenderConvertible for RasterImage {
origin,
color: None,
fill_tris,
plane: None,
run: Some(GlyphRun {
text: path.to_string(),
font,

View file

@ -1187,6 +1187,7 @@ impl RenderConvertible for Table {
oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0),
is_backward: style.map(|s| s.is_backward()).unwrap_or(false),
is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false),
is_vertical: style.map(|s| s.is_vertical).unwrap_or(false),
}
};
@ -1287,6 +1288,8 @@ impl RenderConvertible for Table {
attach_h_anchor,
v_anchor,
line_spacing_factor: 1.0,
exact_line_spacing: false,
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: false,
});
@ -1453,6 +1456,7 @@ pub fn tessellate_table(
oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0),
is_backward: style.map(|s| s.is_backward()).unwrap_or(false),
is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false),
is_vertical: style.map(|s| s.is_vertical).unwrap_or(false),
}
};
@ -1920,6 +1924,8 @@ pub fn tessellate_table(
attach_h_anchor,
v_anchor,
line_spacing_factor: 1.0,
exact_line_spacing: false,
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: false,
});

View file

@ -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);
@ -120,6 +156,7 @@ fn to_render(t: &Text, document: &acadrust::CadDocument) -> RenderEntity {
origin: p.origin,
color: None,
fill_tris,
plane: None,
run: Some(GlyphRun {
text: value,
font: p.font.clone(),
@ -140,7 +177,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 +198,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 +213,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 +260,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 +284,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 +308,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 +319,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 +342,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 +420,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 +537,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 +620,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 +676,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();
}
}

View file

@ -11,6 +11,7 @@ pub struct ResolvedTextStyle {
pub oblique_angle: f32,
pub is_backward: bool,
pub is_upside_down: bool,
pub is_vertical: bool,
}
pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedTextStyle {
@ -76,6 +77,7 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT
oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0),
is_backward: style.map(|s| s.is_backward()).unwrap_or(false),
is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false),
is_vertical: style.map(|s| s.is_vertical).unwrap_or(false),
}
}
@ -1012,6 +1014,11 @@ pub struct MTextRenderOpts<'a> {
pub v_anchor: MTextVAnchor,
/// DXF code 44 — multiplier on the default 5/3-em baseline gap.
pub line_spacing_factor: f32,
/// `true` fixes every baseline advance to the entity spacing. `false`
/// treats it as a minimum and expands for taller inline runs.
pub exact_line_spacing: bool,
/// User-defined text-box/column height. Zero means content-driven.
pub rectangle_height: f32,
/// `true` when the entity is laid out top-to-bottom (DXF code 71 = 2).
pub vertical_text: bool,
/// When true, `layout_mtext` also fills `MTextLayout::glyph_boxes` with
@ -1025,10 +1032,15 @@ pub struct MTextRenderOpts<'a> {
/// An MTEXT's column layout, flattened from its `column_data`.
///
/// Content moves to the next column at a `\N` break. Filling a column and
/// spilling into the next on its own is not modelled: `heights` / `auto_height`
/// are not read, so a column runs as long as its content does.
#[derive(Clone, Copy, Debug, Default)]
/// Content moves to the next column at an explicit `\N` break or when the
/// configured manual/automatic column height is exhausted.
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".
pub count: usize,
@ -1036,6 +1048,12 @@ pub struct MTextColumns {
pub width: f32,
/// Gap between two adjacent columns, world units.
pub gutter: f32,
/// Whether logical column flow starts at the opposite side.
pub flow_reversed: bool,
/// Dynamic-column height is balanced from content when true.
pub auto_height: bool,
/// Optional manual height for each logical column.
pub heights: Vec<f32>,
}
impl MTextColumns {
@ -1046,7 +1064,17 @@ impl MTextColumns {
/// Left edge of column `i`, relative to the text block's own left edge.
pub fn offset_of(&self, i: usize) -> f32 {
i as f32 * (self.width + self.gutter)
let physical = if self.flow_reversed {
self.count.saturating_sub(1).saturating_sub(i)
} else {
i
};
physical as f32 * (self.width + self.gutter)
}
fn total_width(&self) -> f32 {
self.width * self.count as f32
+ self.gutter.max(0.0) * self.count.saturating_sub(1) as f32
}
}
@ -1118,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
@ -1128,7 +1157,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
line_spacing: Option<ParaLineSpacing>,
}
let cols = opts.columns;
let cols = opts.columns.clone();
// A `\N` past the last column has nowhere to go — keep those lines in the
// last one rather than laying them out beyond the block.
let last_col = cols.count.saturating_sub(1);
@ -1255,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.
@ -1277,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,
@ -1366,15 +1397,86 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
};
// A paragraph's own `\psm#`/`\pse#` overrides the entity factor for
// its lines; `Exact` fixes the baseline gap outright.
let entity_gap = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing();
match s.line_spacing {
Some(ParaLineSpacing::Exact(e)) if e > 0.0 => e,
Some(ParaLineSpacing::Multiple(m)) if m > 0.0 => {
mh * m * (5.0 / 3.0) * base_font.line_spacing()
}
_ => mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing(),
_ if opts.exact_line_spacing => entity_gap,
_ => (mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing())
.max(entity_gap),
}
})
.collect();
// Flow lines into the next column when the active column's configured
// height is exhausted. Explicit `\N` breaks remain authoritative. Dynamic
// auto-height balances the content; static/manual columns use their stored
// height (falling back to the entity rectangle height).
if cols.active() {
let 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.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
} else {
cols.heights
.get(current_column)
.copied()
.filter(|height| *height > 0.0)
.unwrap_or(opts.rectangle_height)
};
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
&& current_column + 1 < cols.count
{
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
// inheriting the previous line's height (same rule as `per_line_h`). Unlike
// `line_max_h` it has no `entity_h` floor, so it reflects the ACTUAL text
@ -1442,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];
@ -1734,6 +1841,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
origin,
color,
fill_tris,
plane: None,
run: Some(GlyphRun {
text: s,
font: font_name.to_string(),
@ -1806,6 +1914,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
origin,
color,
fill_tris,
plane: None,
run: Some(GlyphRun {
text: text.clone(),
font: font_name.to_string(),
@ -1840,6 +1949,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
origin,
color,
fill_tris: vec![],
plane: None,
run: None,
});
}
@ -1938,6 +2048,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
origin,
color,
fill_tris,
plane: None,
run: Some(GlyphRun {
text: text.to_string(),
font: font_name.to_string(),
@ -1953,6 +2064,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
@ -1972,9 +2126,15 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
origin: [ox, oy],
color,
fill_tris: Vec::new(),
plane: None,
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 => {
@ -2041,6 +2201,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
origin,
color,
fill_tris: vec![],
plane: None,
run: None,
});
}
@ -2169,6 +2330,7 @@ mod tests {
oblique_angle: 0.0,
is_backward: false,
is_upside_down: false,
is_vertical: false,
}
}
@ -2199,6 +2361,8 @@ mod tests {
attach_h_anchor: 0.0,
v_anchor: MTextVAnchor::Top,
line_spacing_factor: 1.0,
exact_line_spacing: false,
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: false,
});
@ -2222,6 +2386,8 @@ mod tests {
attach_h_anchor: 0.0,
v_anchor: MTextVAnchor::Top,
line_spacing_factor: 1.0,
exact_line_spacing: false,
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: false,
});
@ -2378,6 +2544,7 @@ mod v_anchor_tests {
oblique_angle: 0.0,
is_backward: false,
is_upside_down: false,
is_vertical: false,
};
layout_mtext(&MTextRenderOpts {
columns: Default::default(),
@ -2390,6 +2557,8 @@ mod v_anchor_tests {
attach_h_anchor: 0.0,
v_anchor: MTextVAnchor::Top,
line_spacing_factor: 1.0,
exact_line_spacing: false,
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: true,
})

View file

@ -3,7 +3,9 @@ use acadrust::entities::Tolerance;
use crate::command::EntityTransform;
use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible};
use crate::scene::convert::acad_to_render::{GlyphRun, TextStroke, RenderEntity, RenderObject};
use crate::scene::convert::acad_to_render::{
GlyphRun, RenderEntity, RenderObject, TextPlane, TextStroke,
};
use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
use crate::scene::model::wire_model::SnapHint;
use crate::scene::text::lff;
@ -131,7 +133,7 @@ fn parse_cell(s: &str) -> Cell {
/// (`\Fgdt.shx|b0|i0|c134|p6;j`), so match the name and skip to the `;` rather
/// than demanding the bare `\Fgdt;` form — the parameterised spelling is legal
/// and would otherwise drop the symbol silently.
fn symbol_font_switch(inner: &str) -> Option<char> {
pub(crate) fn symbol_font_switch(inner: &str) -> Option<char> {
let rest = inner
.strip_prefix("\\F")
.or_else(|| inner.strip_prefix("\\f"))?;
@ -247,15 +249,13 @@ fn content_width(
/// One text run of a feature-control frame, ready to become a `TextStroke`
/// with a `GlyphRun` so the run can render as SDF glyph quads (or, when
/// SDF is off, from `strokes`). `origin` is relative to the tolerance insertion
/// point (already rotated); `strokes` are the glyph polylines rotated about the
/// origin (no origin translation — the wire-builder adds the origin).
/// point; `strokes` remain in the frame's local plane.
struct TolCell {
text: String,
font: String,
origin: [f32; 2],
strokes: Vec<Vec<[f32; 2]>>,
height: f32,
rotation: f32,
width_factor: f32,
oblique: f32,
}
@ -263,8 +263,8 @@ struct TolCell {
/// Tessellate a Tolerance entity's feature-control frame.
///
/// Returns (`box_strokes`, `cells`):
/// - `box_strokes` — the outer border, row separators and column dividers as
/// 2-D polylines (rotated; run-less, so they always render as strokes)
/// - `box_strokes` — the outer border, row separators and column dividers in
/// frame-local coordinates
/// - `cells` — one [`TolCell`] per non-empty cell, carrying its text + a
/// `GlyphRun` so the cell renders as SDF text (frame stays geometry).
fn tessellate_tolerance(
@ -341,13 +341,6 @@ fn tessellate_tolerance(
.map(|row| row.iter().map(|c| cell_width(c)).collect())
.collect();
// ── Transform helpers (local space — translation applied in tessellate.rs) ──
let angle = (tol.direction.y as f32).atan2(tol.direction.x as f32);
let (sa, ca) = angle.sin_cos();
// Rotate only; origin is kept as f64 and applied later with full precision.
let rot = |x: f32, y: f32| -> [f32; 2] { [x * ca - y * sa, x * sa + y * ca] };
let mut box_out: Vec<Vec<[f32; 2]>> = Vec::new();
let mut cells: Vec<TolCell> = Vec::new();
@ -370,11 +363,11 @@ fn tessellate_tolerance(
let y0 = row_bottom(ri);
let y1 = y0 + cell_h;
box_out.push(vec![
rot(0.0, y0),
rot(rw, y0),
rot(rw, y1),
rot(0.0, y1),
rot(0.0, y0),
[0.0, y0],
[rw, y0],
[rw, y1],
[0.0, y1],
[0.0, y0],
]);
}
@ -385,15 +378,13 @@ fn tessellate_tolerance(
let mut x_cursor = 0.0_f32;
for w in widths.iter().take(widths.len().saturating_sub(1)) {
x_cursor += w;
box_out.push(vec![rot(x_cursor, y0), rot(x_cursor, y1)]);
box_out.push(vec![[x_cursor, y0], [x_cursor, y1]]);
}
}
// ── Text content per cell ─────────────────────────────────────────────
// Each cell becomes a TolCell: its origin is the (rotated) cell position
// relative to the insertion point; its `strokes` are the glyph polylines
// rotated about that origin (used only when SDF is off). The GlyphRun the
// caller attaches lets the cell render as SDF text.
// Each cell stays in frame-local coordinates; the renderer maps the frame
// into its world-space plane.
for (ri, row) in rows.iter().enumerate() {
// The compartment is 2h tall and the character h, so an h/2 margin
// centres the text on the row's centreline.
@ -427,21 +418,17 @@ fn tessellate_tolerance(
&font,
&text,
);
// Glyph polylines rotated about the run's origin (no origin
// translation — the wire-builder adds `origin`).
let strokes: Vec<Vec<[f32; 2]>> = local_strokes
.into_iter()
.map(|pl| pl.into_iter().map(|[px, py]| rot(px, py)).collect())
.filter(|pl: &Vec<[f32; 2]>| !pl.is_empty())
.collect();
let advance = run_advance(&text, &font, h, width_factor, oblique);
cells.push(TolCell {
text,
font,
origin: rot(run_x, row_y),
origin: [run_x, row_y],
strokes,
height: h,
rotation: angle,
width_factor,
oblique,
});
@ -455,6 +442,24 @@ fn tessellate_tolerance(
(box_out, cells)
}
pub(crate) fn preview_strokes(
tol: &Tolerance,
doc: &acadrust::CadDocument,
) -> Vec<Vec<[f32; 2]>> {
tessellate_tolerance(tol, doc).0
}
fn frame_basis(tol: &Tolerance) -> (glam::DVec3, glam::DVec3) {
let normal = glam::DVec3::new(tol.normal.x, tol.normal.y, tol.normal.z)
.normalize_or(glam::DVec3::Z);
let direction = glam::DVec3::new(tol.direction.x, tol.direction.y, tol.direction.z);
let fallback = crate::scene::view::transform::ocs_axes((normal.x, normal.y, normal.z)).0;
let fallback = glam::DVec3::new(fallback.0, fallback.1, fallback.2);
let x_axis = (direction - normal * direction.dot(normal)).normalize_or(fallback);
let y_axis = normal.cross(x_axis).normalize_or(glam::DVec3::Y);
(x_axis, y_axis)
}
// ── RenderConvertible ──────────────────────────────────────────────────────────
impl RenderConvertible for Tolerance {
@ -471,7 +476,18 @@ impl RenderConvertible for Tolerance {
// Build the feature-control frame in local space; origin stored as f64.
let (box_strokes, cells) = tessellate_tolerance(self, document);
let ins = [self.insertion_point.x, self.insertion_point.y];
let ins = glam::DVec3::new(
self.insertion_point.x,
self.insertion_point.y,
self.insertion_point.z,
);
let (x_axis, y_axis) = frame_basis(self);
let plane = |origin: glam::DVec3| TextPlane {
origin: origin.to_array(),
scale_origin: ins.to_array(),
x_axis: x_axis.to_array(),
y_axis: y_axis.to_array(),
};
let style = resolve_dim_style(self, document);
let xd = &self.common.extended_data;
let explicit_color = |index: Option<i16>| {
@ -520,9 +536,10 @@ impl RenderConvertible for Tolerance {
if !box_strokes.is_empty() {
groups.push(TextStroke {
strokes: box_strokes,
origin: ins,
origin: [ins.x, ins.y],
color: frame_color,
fill_tris: vec![],
plane: Some(plane(ins)),
run: None,
});
}
@ -530,16 +547,21 @@ impl RenderConvertible for Tolerance {
groups.push(TextStroke {
strokes: cell.strokes,
origin: [
ins[0] + cell.origin[0] as f64,
ins[1] + cell.origin[1] as f64,
ins.x + x_axis.x * cell.origin[0] as f64
+ y_axis.x * cell.origin[1] as f64,
ins.y + x_axis.y * cell.origin[0] as f64
+ y_axis.y * cell.origin[1] as f64,
],
color: text_color,
fill_tris: vec![],
plane: Some(plane(
ins + x_axis * cell.origin[0] as f64 + y_axis * cell.origin[1] as f64,
)),
run: Some(GlyphRun {
text: cell.text,
font: cell.font,
height: cell.height,
rotation: cell.rotation,
rotation: 0.0,
width_factor: cell.width_factor,
oblique: cell.oblique,
// `tracking` scales the font's own letter spacing, so 0

View file

@ -1,7 +1,11 @@
use crate::command::{CadCommand, CmdResult};
use acadrust::entities::mtext::AttachmentPoint;
use acadrust::types::Vector3;
use acadrust::MText;
use glam::DVec3;
use crate::command::{CadCommand, CmdOption, CmdResult, DynField, WorkingPlane};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
use glam::DVec3;
use crate::t;
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/mtext.svg"));
@ -15,20 +19,180 @@ pub fn tool() -> ToolDef {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Step {
InsertPoint,
FirstCorner,
OppositeCorner,
Height,
Justify,
LineSpacing,
Rotation,
Style,
Width,
ColumnMode,
ColumnCount,
ColumnWidth,
ColumnGutter,
}
pub struct MTextCommand {
step: Step,
first: Option<DVec3>,
plane: WorkingPlane,
height: f64,
style: String,
attachment: AttachmentPoint,
line_spacing: f64,
rotation: f64,
width: Option<f64>,
column_type: i16,
column_count: i32,
column_width: f64,
column_gutter: f64,
}
impl MTextCommand {
pub fn with_height(height: f64) -> Self {
pub fn with_defaults(height: f64, style: String) -> Self {
Self {
step: Step::InsertPoint,
height,
step: Step::FirstCorner,
first: None,
plane: WorkingPlane::default(),
height: height.max(1e-6),
style,
attachment: AttachmentPoint::TopLeft,
line_spacing: 1.0,
rotation: 0.0,
width: None,
column_type: 0,
column_count: 2,
column_width: 0.0,
column_gutter: 0.0,
}
}
fn resume_point_step(&mut self) {
self.step = if self.first.is_some() {
Step::OppositeCorner
} else {
Step::FirstCorner
};
}
fn point_options() -> Vec<CmdOption> {
vec![
CmdOption::new("Height", "HEIGHT"),
CmdOption::new("Justify", "JUSTIFY"),
CmdOption::new("Line spacing", "LINESPACING"),
CmdOption::new("Rotation", "ROTATION"),
CmdOption::new("Style", "STYLE"),
CmdOption::new("Width", "WIDTH"),
CmdOption::new("Columns", "COLUMNS"),
]
}
fn begin_option(&mut self, keyword: &str) -> bool {
self.step = match keyword {
"H" | "HEIGHT" => Step::Height,
"J" | "JUSTIFY" => Step::Justify,
"L" | "LINESPACING" | "LINE SPACING" => Step::LineSpacing,
"R" | "ROTATION" => Step::Rotation,
"S" | "STYLE" => Step::Style,
"W" | "WIDTH" => Step::Width,
"C" | "COLUMNS" => Step::ColumnMode,
_ => return false,
};
true
}
fn boundary_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 picked_horizontal = local.x * cos + local.y * sin;
let vertical = -local.x * sin + local.y * cos;
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 (insertion, width, boundary_height) = self.boundary_geometry(opposite);
let mut template = MText::default();
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);
template.rotation = self.rotation;
template.style = self.style.clone();
template.attachment_point = self.attachment;
template.line_spacing_factor = self.line_spacing;
template.column_data.column_type = self.column_type;
if self.column_type != 0 {
template.column_data.column_count =
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
} else if width > 0.0 {
width
} else {
self.height * 10.0
};
template.column_data.gutter = if self.column_gutter > 0.0 {
self.column_gutter
} else {
self.height
};
}
CmdResult::OpenMTextEditor {
pos: insertion,
handle: None,
initial: String::new(),
height: self.height,
template: Some(Box::new(template)),
}
}
}
@ -38,34 +202,208 @@ impl CadCommand for MTextCommand {
"MTEXT"
}
fn set_working_plane(&mut self, plane: WorkingPlane) {
self.plane = plane;
}
fn prompt(&self) -> String {
match &self.step {
Step::InsertPoint => t!("MTEXT Specify insertion point:").into_owned(),
match self.step {
Step::FirstCorner => t!("MTEXT Specify first corner:").into_owned(),
Step::OppositeCorner => t!("MTEXT Specify opposite corner:").into_owned(),
Step::Height => crate::tf!("MTEXT Specify text height <{}>:", self.height).into_owned(),
Step::Justify => t!("MTEXT Enter justification [TL / TC / TR / ML / MC / MR / BL / BC / BR] <TL>:").into_owned(),
Step::LineSpacing => crate::tf!("MTEXT Enter line spacing factor (0.25-4.00) <{}>:", self.line_spacing).into_owned(),
Step::Rotation => crate::tf!("MTEXT Specify rotation angle <{}>:", self.rotation.to_degrees()).into_owned(),
Step::Style => crate::tf!("MTEXT Enter text style <{}>:", self.style).into_owned(),
Step::Width => crate::tf!("MTEXT Specify boundary width <{}>:", self.width.unwrap_or(0.0)).into_owned(),
Step::ColumnMode => t!("MTEXT Columns [None / Static / Dynamic] <None>:").into_owned(),
Step::ColumnCount => crate::tf!("MTEXT Enter column count <{}>:", self.column_count).into_owned(),
Step::ColumnWidth => crate::tf!("MTEXT Enter column width <{}>:", self.column_width).into_owned(),
Step::ColumnGutter => crate::tf!("MTEXT Enter column gutter <{}>:", self.column_gutter).into_owned(),
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
// Hand off to the in-place editor (toolbar + text area + live preview).
CmdResult::OpenMTextEditor {
pos: pt,
handle: None,
initial: String::new(),
height: self.height,
fn options(&self) -> Vec<CmdOption> {
match self.step {
Step::FirstCorner | Step::OppositeCorner => Self::point_options(),
Step::Justify => ["TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"]
.into_iter()
.map(|value| CmdOption::new(value, value))
.collect(),
Step::ColumnMode => vec![
CmdOption::new("None", "NONE"),
CmdOption::new("Static", "STATIC"),
CmdOption::new("Dynamic", "DYNAMIC"),
],
_ => Vec::new(),
}
}
fn point_step_accepts_keywords(&self) -> bool {
matches!(self.step, Step::FirstCorner | Step::OppositeCorner)
}
fn wants_text_input(&self) -> bool {
true
}
fn wants_text_with_spaces(&self) -> bool {
matches!(self.step, Step::Style)
}
fn dyn_field(&self) -> DynField {
match self.step {
Step::Rotation => DynField::Angle,
Step::Height
| Step::LineSpacing
| Step::Width
| Step::ColumnCount
| Step::ColumnWidth
| Step::ColumnGutter => DynField::Scalar,
_ => DynField::Point,
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let trimmed = text.trim();
let upper = trimmed.to_ascii_uppercase();
if matches!(self.step, Step::FirstCorner | Step::OppositeCorner)
&& self.begin_option(&upper)
{
return Some(CmdResult::NeedPoint);
}
match self.step {
Step::Height => {
let value = crate::entities::common::parse_typed_length(trimmed)?;
if value <= 0.0 {
return None;
}
self.height = value;
self.resume_point_step();
}
Step::Justify => {
self.attachment = match upper.as_str() {
"TL" => AttachmentPoint::TopLeft,
"TC" => AttachmentPoint::TopCenter,
"TR" => AttachmentPoint::TopRight,
"ML" => AttachmentPoint::MiddleLeft,
"MC" => AttachmentPoint::MiddleCenter,
"MR" => AttachmentPoint::MiddleRight,
"BL" => AttachmentPoint::BottomLeft,
"BC" => AttachmentPoint::BottomCenter,
"BR" => AttachmentPoint::BottomRight,
_ => return None,
};
self.resume_point_step();
}
Step::LineSpacing => {
let value = trimmed.replace(',', ".").parse::<f64>().ok()?;
if !(0.25..=4.0).contains(&value) {
return None;
}
self.line_spacing = value;
self.resume_point_step();
}
Step::Rotation => {
self.rotation = crate::entities::common::parse_typed_direction(trimmed)?;
self.resume_point_step();
}
Step::Style => {
if trimmed.is_empty() {
return None;
}
self.style = trimmed.to_string();
self.resume_point_step();
}
Step::Width => {
let value = crate::entities::common::parse_typed_length(trimmed)?;
if value < 0.0 {
return None;
}
self.width = Some(value);
self.resume_point_step();
}
Step::ColumnMode => {
self.column_type = match upper.as_str() {
"N" | "NONE" => 0,
"S" | "STATIC" => 1,
"D" | "DYNAMIC" => 2,
_ => return None,
};
if self.column_type == 0 {
self.resume_point_step();
} else {
self.step = Step::ColumnCount;
}
}
Step::ColumnCount => {
let value = trimmed.parse::<i32>().ok()?;
if !(1..=crate::entities::text_support::MAX_MTEXT_COLUMNS).contains(&value) {
return None;
}
self.column_count = value;
self.step = Step::ColumnWidth;
}
Step::ColumnWidth => {
let value = crate::entities::common::parse_typed_length(trimmed)?;
if value <= 0.0 {
return None;
}
self.column_width = value;
self.step = Step::ColumnGutter;
}
Step::ColumnGutter => {
let value = crate::entities::common::parse_typed_length(trimmed)?;
if value < 0.0 {
return None;
}
self.column_gutter = value;
self.resume_point_step();
}
Step::FirstCorner | Step::OppositeCorner => return None,
}
Some(CmdResult::NeedPoint)
}
fn on_point(&mut self, point: DVec3) -> CmdResult {
match self.step {
Step::FirstCorner => {
self.first = Some(point);
self.step = Step::OppositeCorner;
CmdResult::NeedPoint
}
Step::OppositeCorner => self.open_editor(point),
_ => CmdResult::NeedPoint,
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
match self.step {
Step::Height
| Step::Justify
| Step::LineSpacing
| Step::Rotation
| Step::Style
| Step::Width
| Step::ColumnMode
| Step::ColumnCount
| Step::ColumnWidth
| Step::ColumnGutter => {
self.resume_point_step();
CmdResult::NeedPoint
}
_ => CmdResult::Cancel,
}
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_mouse_move(&mut self, _pt: DVec3) -> Option<WireModel> {
fn on_mouse_move(&mut self, _point: DVec3) -> Option<WireModel> {
None
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] }); // MTextCommand
inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] });

View file

@ -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 {
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"] });

View file

@ -25,13 +25,15 @@ pub fn tool() -> ToolDef {
pub struct ToleranceCommand {
text: String,
preview_strokes: Vec<Vec<[f32; 2]>>,
plane: WorkingPlane,
}
impl ToleranceCommand {
pub fn with_text(text: String) -> Self {
pub fn with_text(text: String, preview_strokes: Vec<Vec<[f32; 2]>>) -> Self {
Self {
text,
preview_strokes,
plane: WorkingPlane::default(),
}
}
@ -66,48 +68,17 @@ impl CadCommand for ToleranceCommand {
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
let normalized = self
.text
.replace("^J", "\n")
.replace("\\P", "\n")
.replace("\r\n", "\n")
.replace('\r', "\n");
let rows: Vec<Vec<&str>> = normalized
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
let mut cells: Vec<&str> = line.split("%%v").collect();
while cells.last().is_some_and(|cell| cell.trim().is_empty()) {
cells.pop();
}
cells
})
.filter(|cells| !cells.is_empty())
.collect();
let row_height = 0.35;
let cell_width = 0.5;
let mut points = Vec::new();
let mut segment = |a: DVec3, b: DVec3| {
for stroke in &self.preview_strokes {
if stroke.len() < 2 {
continue;
}
if !points.is_empty() {
points.push(DVec3::splat(f64::NAN));
}
points.push(a);
points.push(b);
};
for (row_index, cells) in rows.iter().enumerate() {
let count = cells.len().max(1);
let y0 = -row_height * (row_index as f64 + 0.5);
let y1 = y0 + row_height;
let x1 = cell_width * count as f64;
let p = |x: f64, y: f64| pt + self.plane.x * x + self.plane.y * y;
segment(p(0.0, y0), p(x1, y0));
segment(p(x1, y0), p(x1, y1));
segment(p(x1, y1), p(0.0, y1));
segment(p(0.0, y1), p(0.0, y0));
for index in 1..count {
let x = cell_width * index as f64;
segment(p(x, y0), p(x, y1));
}
points.extend(stroke.iter().map(|[x, y]| {
pt + self.plane.x * *x as f64 + self.plane.y * *y as f64
}));
}
Some(WireModel {
point_marker: None,

View file

@ -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
}
}

View file

@ -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,
}

View file

@ -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

View file

@ -94,7 +94,9 @@ pub fn general_section(entity: &EntityType) -> PropSection {
],
};
if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline))
if matches!(entity, EntityType::Text(_))
|| matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline))
|| matches!(entity, EntityType::MText(_))
|| matches!(entity, EntityType::Tolerance(_))
{
section.props.retain(|prop| prop.field != "handle");

View file

@ -24,6 +24,7 @@ pub struct TextStroke {
pub origin: [f64; 2],
pub color: Option<[f32; 3]>,
pub fill_tris: Vec<[f32; 2]>,
pub plane: Option<TextPlane>,
/// Layout inputs to rebuild this run as per-glyph SDF quads (see
/// `scene::text::glyph_quads`). `Some` on runs wired for the SDF text
/// renderer; `None` leaves the run to the stroke path only. Heights are
@ -32,6 +33,14 @@ pub struct TextStroke {
pub run: Option<GlyphRun>,
}
#[derive(Clone, Copy, Debug)]
pub struct TextPlane {
pub origin: [f64; 3],
pub scale_origin: [f64; 3],
pub x_axis: [f64; 3],
pub y_axis: [f64; 3],
}
/// Per-run text-layout inputs needed to reproduce a run as SDF glyph quads.
#[derive(Clone, Debug)]
pub struct GlyphRun {

View file

@ -528,7 +528,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);

View file

@ -78,6 +78,81 @@ fn oriented_text_corners(
]
}
fn oriented_mtext_corner_groups(
verts: &[crate::scene::pipeline::text_gpu::TextVertex],
text: &acadrust::MText,
rotation: f64,
pad: f64,
annotation_scale: f64,
) -> Vec<[[f64; 2]; 4]> {
let columns = &text.column_data;
let count = 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,
[text.insertion_point.x, text.insertion_point.y],
rotation,
pad,
)];
}
let width = columns.width * annotation_scale;
let gutter = columns.gutter.max(0.0) * annotation_scale;
let total_width = width * count as f64 + gutter * count.saturating_sub(1) as f64;
let anchor = match text.attachment_point {
acadrust::entities::mtext::AttachmentPoint::TopCenter
| acadrust::entities::mtext::AttachmentPoint::MiddleCenter
| acadrust::entities::mtext::AttachmentPoint::BottomCenter => 0.5,
acadrust::entities::mtext::AttachmentPoint::TopRight
| acadrust::entities::mtext::AttachmentPoint::MiddleRight
| acadrust::entities::mtext::AttachmentPoint::BottomRight => 1.0,
_ => 0.0,
};
let block_left = -anchor * total_width;
let origin = [text.insertion_point.x, text.insertion_point.y];
let (sin_r, cos_r) = rotation.sin_cos();
let mut bounds = vec![[f64::MAX, f64::MAX, f64::MIN, f64::MIN]; count];
for vertex in verts {
let x = vertex.pos[0] as f64 + vertex.pos_low[0] as f64 - origin[0];
let y = vertex.pos[1] as f64 + vertex.pos_low[1] as f64 - origin[1];
let local_x = x * cos_r + y * sin_r;
let local_y = -x * sin_r + y * cos_r;
let stride = width + gutter;
let physical = ((local_x - block_left) / stride)
.floor()
.clamp(0.0, count.saturating_sub(1) as f64) as usize;
bounds[physical][0] = bounds[physical][0].min(local_x);
bounds[physical][1] = bounds[physical][1].min(local_y);
bounds[physical][2] = bounds[physical][2].max(local_x);
bounds[physical][3] = bounds[physical][3].max(local_y);
}
let to_world = |x: f64, y: f64| {
[
origin[0] + x * cos_r - y * sin_r,
origin[1] + x * sin_r + y * cos_r,
]
};
bounds
.into_iter()
.filter(|bounds| bounds[0] <= bounds[2] && bounds[1] <= bounds[3])
.map(|bounds| {
let [left, bottom, right, top] = [
bounds[0] - pad,
bounds[1] - pad,
bounds[2] + pad,
bounds[3] + pad,
];
[
to_world(left, bottom),
to_world(right, bottom),
to_world(right, top),
to_world(left, top),
]
})
.collect()
}
pub(crate) fn explicit_mtext_background(entity: &EntityType) -> Option<[f32; 4]> {
let EntityType::MText(text) = entity else {
return None;
@ -633,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 ───────────────
@ -733,9 +815,32 @@ pub fn tessellate(
}
bin_first[bi] = false;
for &[x, y] in stroke {
let xv = x as f64 * anno + slx_v;
let yv = y as f64 * anno + sly_v;
let (h, l) = split_ds_xyz(xv, yv, elev_v);
let world = if let Some(plane) = group.plane {
let scaled_origin = [
plane.scale_origin[0]
+ (plane.origin[0] - plane.scale_origin[0]) * anno,
plane.scale_origin[1]
+ (plane.origin[1] - plane.scale_origin[1]) * anno,
plane.scale_origin[2]
+ (plane.origin[2] - plane.scale_origin[2]) * anno,
];
let x = x as f64 * anno;
let y = y as f64 * anno;
[
scaled_origin[0]
+ plane.x_axis[0] * x
+ plane.y_axis[0] * y,
scaled_origin[1]
+ plane.x_axis[1] * x
+ plane.y_axis[1] * y,
scaled_origin[2]
+ plane.x_axis[2] * x
+ plane.y_axis[2] * y,
]
} else {
[x as f64 * anno + slx_v, y as f64 * anno + sly_v, elev_v]
};
let (h, l) = split_ds_xyz(world[0], world[1], world[2]);
bins[bi].pts.push(h);
bins[bi].pts_low.push(l);
}
@ -743,9 +848,32 @@ pub fn tessellate(
// 2. Process fill triangles
for &[x, y] in &group.fill_tris {
let xv = x as f64 * anno + slx_v;
let yv = y as f64 * anno + sly_v;
let (h, l) = split_ds_xyz(xv, yv, elev_v);
let world = if let Some(plane) = group.plane {
let scaled_origin = [
plane.scale_origin[0]
+ (plane.origin[0] - plane.scale_origin[0]) * anno,
plane.scale_origin[1]
+ (plane.origin[1] - plane.scale_origin[1]) * anno,
plane.scale_origin[2]
+ (plane.origin[2] - plane.scale_origin[2]) * anno,
];
let x = x as f64 * anno;
let y = y as f64 * anno;
[
scaled_origin[0]
+ plane.x_axis[0] * x
+ plane.y_axis[0] * y,
scaled_origin[1]
+ plane.x_axis[1] * x
+ plane.y_axis[1] * y,
scaled_origin[2]
+ plane.x_axis[2] * x
+ plane.y_axis[2] * y,
]
} else {
[x as f64 * anno + slx_v, y as f64 * anno + sly_v, elev_v]
};
let (h, l) = split_ds_xyz(world[0], world[1], world[2]);
bins[bi].fill_tris.push(h);
bins[bi].fill_tris_low.push(l);
}
@ -793,6 +921,26 @@ pub fn tessellate(
run.bold,
&run.text,
);
if let Some(plane) = group.plane {
let scaled_origin = [
plane.scale_origin[0]
+ (plane.origin[0] - plane.scale_origin[0]) * anno,
plane.scale_origin[1]
+ (plane.origin[1] - plane.scale_origin[1]) * anno,
plane.scale_origin[2]
+ (plane.origin[2] - plane.scale_origin[2]) * anno,
];
crate::scene::pipeline::text_gpu::push_glyph_vertices_on_plane(
&mut sdf_verts,
&quads,
scaled_origin,
plane.x_axis,
plane.y_axis,
anno,
gcolor,
0.0,
);
} else {
crate::scene::pipeline::text_gpu::push_glyph_vertices(
&mut sdf_verts,
&quads,
@ -804,6 +952,7 @@ pub fn tessellate(
}
}
}
}
let snap_pts = te.snap_pts;
let key_vertices: Vec<[f64; 3]> = te
@ -849,11 +998,12 @@ pub fn tessellate(
.unwrap_or(m.rotation);
let text_height = m.height * anno;
let pad = (m.background_scale - 1.0).max(0.0) * text_height;
let corners = oriented_text_corners(
let corner_groups = oriented_mtext_corner_groups(
&sdf_verts,
[m.insertion_point.x, m.insertion_point.y],
m,
text_rotation,
pad,
anno,
);
// Fill / mask — two triangles behind the glyphs.
if has_fill {
@ -862,14 +1012,19 @@ pub fn tessellate(
} else {
color_or_inherit(&m.background_color, bg_color)
};
let mut ft = Vec::with_capacity(6);
let mut ftl = Vec::with_capacity(6);
let mut ft = Vec::with_capacity(6 * corner_groups.len());
let mut ftl = Vec::with_capacity(6 * corner_groups.len());
for corners in &corner_groups {
for &k in &[0usize, 1, 2, 0, 2, 3] {
let (h, lo) =
split_ds_xyz(corners[k][0], corners[k][1], elev_v);
let (h, lo) = split_ds_xyz(
corners[k][0],
corners[k][1],
elev_v,
);
ft.push(h);
ftl.push(lo);
}
}
wires.push(WireModel {
point_marker: None,
taper_widths: Vec::new(),
@ -907,20 +1062,25 @@ pub fn tessellate(
// Text frame — a closed rectangle in the text
// colour around the same box.
if has_frame {
let loop_xy = [
let mut fp = Vec::with_capacity(6 * corner_groups.len());
let mut fpl = Vec::with_capacity(6 * corner_groups.len());
for (group_index, corners) in corner_groups.iter().enumerate() {
if group_index > 0 {
fp.push([f32::NAN; 3]);
fpl.push([0.0; 3]);
}
for &[x, y] in &[
corners[0],
corners[1],
corners[2],
corners[3],
corners[0],
];
let mut fp = Vec::with_capacity(5);
let mut fpl = Vec::with_capacity(5);
for &[x, y] in &loop_xy {
] {
let (h, lo) = split_ds_xyz(x, y, elev_v);
fp.push(h);
fpl.push(lo);
}
}
wires.push(WireModel {
point_marker: None,
taper_widths: Vec::new(),
@ -1718,24 +1878,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 {

View file

@ -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 {

View file

@ -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();
}
}
}

View file

@ -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)
}
if vp.id > 1 {
_ => 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 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);
}

View file

@ -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

View file

@ -151,6 +151,51 @@ pub fn push_glyph_vertices(
}
}
#[allow(clippy::too_many_arguments)]
pub fn push_glyph_vertices_on_plane(
out: &mut Vec<TextVertex>,
quads: &[GlyphQuad],
origin: [f64; 3],
x_axis: [f64; 3],
y_axis: [f64; 3],
anno: f64,
color: [f32; 4],
draw_depth: f32,
) {
for q in quads {
let mk = |ci: usize, uv: [f32; 2]| -> TextVertex {
let c = q.corners[ci];
let x = c[0] as f64 * anno;
let y = c[1] as f64 * anno;
let world = [
origin[0] + x_axis[0] * x + y_axis[0] * y,
origin[1] + x_axis[1] * x + y_axis[1] * y,
origin[2] + x_axis[2] * x + y_axis[2] * y,
];
let (xh, xl) = split_ds(world[0]);
let (yh, yl) = split_ds(world[1]);
let (zh, zl) = split_ds(world[2]);
TextVertex {
pos: [xh, yh, zh],
pos_low: [xl, yl, zl],
uv,
color,
draw_depth,
}
};
let bl = [q.uv_min[0], q.uv_max[1]];
let br = [q.uv_max[0], q.uv_max[1]];
let tr = [q.uv_max[0], q.uv_min[1]];
let tl = [q.uv_min[0], q.uv_min[1]];
out.push(mk(0, bl));
out.push(mk(1, br));
out.push(mk(2, tr));
out.push(mk(0, bl));
out.push(mk(2, tr));
out.push(mk(3, tl));
}
}
/// Slide every glyph vertex by a world-space delta, re-splitting the
/// double-single position. Lets a grip drag move already-shaped text by
/// translating the drag-start glyphs each frame instead of re-tessellating

View file

@ -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);
}
}

View file

@ -38,6 +38,11 @@ pub struct State {
pub projected_height: String,
pub projected_zone: bool,
pub datum_identifier: String,
plain_frame: bool,
symbol_tail: String,
extra_rows: Vec<String>,
original_text: Option<String>,
dirty: bool,
}
impl Default for State {
@ -50,6 +55,11 @@ impl Default for State {
projected_height: String::new(),
projected_zone: false,
datum_identifier: String::new(),
plain_frame: false,
symbol_tail: String::new(),
extra_rows: Vec::new(),
original_text: None,
dirty: false,
}
}
}
@ -130,16 +140,32 @@ fn escape(code: &str) -> String {
}
}
fn strip_escape(input: &str, code: &str) -> Option<String> {
let marker = escape(code);
input
.strip_suffix(&marker)
.map(std::string::ToString::to_string)
fn remove_escape(input: &str, code: &str) -> Option<String> {
let target = code.chars().next()?;
for (start, ch) in input.char_indices() {
if ch != '{' {
continue;
}
let tail = &input[start + 1..];
let Some(relative_end) = tail.find('}') else {
break;
};
let end = start + 1 + relative_end;
if crate::entities::tolerance::symbol_font_switch(&input[start + 1..end])
== Some(target)
{
let mut remaining = String::with_capacity(input.len() - (end + 1 - start));
remaining.push_str(&input[..start]);
remaining.push_str(&input[end + 1..]);
return Some(remaining);
}
}
None
}
fn parse_material(input: &str) -> (String, String) {
for code in ["m", "l", "s"] {
if let Some(value) = strip_escape(input, code) {
if let Some(value) = remove_escape(input, code) {
return (value, code.to_string());
}
}
@ -157,26 +183,32 @@ impl State {
.replace("\\P", "\n")
.replace("\r\n", "\n")
.replace('\r', "\n");
let mut lines = normalized.lines();
let lines: Vec<&str> = normalized.split('\n').collect();
state.original_text = (!raw.is_empty()).then(|| raw.to_string());
if let Some(frame) = lines.next() {
if let Some(frame) = lines.first().copied() {
let cells: Vec<&str> = frame.split("%%v").collect();
if let Some(cell) = cells.first() {
state.symbol = SYMBOLS
.iter()
.find_map(|(code, _)| {
(!code.is_empty() && cell.contains(&escape(code))).then(|| code.to_string())
})
.unwrap_or_default();
let mut remaining = (*cell).to_string();
for (code, _) in SYMBOLS.iter().filter(|(code, _)| !code.is_empty()) {
if let Some(value) = remove_escape(&remaining, code) {
state.symbol = (*code).to_string();
remaining = value;
break;
}
}
state.symbol_tail = remaining;
}
state.plain_frame = !raw.is_empty() && cells.len() == 1 && state.symbol.is_empty();
if state.plain_frame {
state.tolerances[0].value = frame.to_string();
state.symbol_tail.clear();
} else {
for index in 0..2 {
if let Some(cell) = cells.get(index + 1) {
let (cell, diameter) = if let Some(rest) = cell.strip_prefix(&escape("n")) {
(rest, true)
} else {
(*cell, false)
};
let (value, material) = parse_material(cell);
let (cell, diameter) = remove_escape(cell, "n")
.map_or_else(|| ((*cell).to_string(), false), |value| (value, true));
let (value, material) = parse_material(&cell);
state.tolerances[index] = ToleranceEntry {
diameter,
value,
@ -190,82 +222,135 @@ impl State {
state.datums[index] = DatumEntry { value, material };
}
}
if cells.len() == 1
&& state.symbol.is_empty()
&& !frame.trim().is_empty()
{
state.tolerances[0].value = frame.trim().to_string();
}
}
if let Some(projected) = lines.next() {
if let Some(value) = strip_escape(projected, "p") {
let mut next = 1;
if let Some(projected) = lines.get(next).copied() {
if let Some(value) = remove_escape(projected, "p") {
state.projected_height = value;
state.projected_zone = true;
} else {
next += 1;
} else if lines.get(next + 1).is_some()
&& !projected.contains("%%v")
&& !lines[next + 1].contains("%%v")
{
state.projected_height = projected.to_string();
next += 1;
}
}
if let Some(identifier) = lines.next() {
if next > 1 {
if let Some(identifier) = lines.get(next).copied() {
if !identifier.contains("%%v") {
state.datum_identifier = identifier.to_string();
next += 1;
}
}
}
state.extra_rows = lines[next..].iter().map(|row| (*row).to_string()).collect();
state
}
pub fn apply_field(&mut self, field: Field) {
match field {
Field::Symbol(value) => self.symbol = value,
let changed = match field {
Field::Symbol(value) => {
let changed = self.symbol != value;
self.symbol = value;
changed
}
Field::ToleranceValue(index, value) if index < 2 => {
self.tolerances[index].value = value
let changed = self.tolerances[index].value != value;
self.tolerances[index].value = value;
changed
}
Field::ToleranceMaterial(index, value) if index < 2 => {
self.tolerances[index].material = value
let changed = self.tolerances[index].material != value;
self.tolerances[index].material = value;
changed
}
Field::DatumValue(index, value) if index < 3 => {
let changed = self.datums[index].value != value;
self.datums[index].value = value;
changed
}
Field::DatumValue(index, value) if index < 3 => self.datums[index].value = value,
Field::DatumMaterial(index, value) if index < 3 => {
self.datums[index].material = value
let changed = self.datums[index].material != value;
self.datums[index].material = value;
changed
}
Field::ProjectedHeight(value) => self.projected_height = value,
Field::DatumIdentifier(value) => self.datum_identifier = value,
_ => {}
Field::ProjectedHeight(value) => {
let changed = self.projected_height != value;
self.projected_height = value;
changed
}
Field::DatumIdentifier(value) => {
let changed = self.datum_identifier != value;
self.datum_identifier = value;
changed
}
_ => false,
};
self.dirty |= changed;
}
pub fn apply_toggle(&mut self, toggle: Toggle) {
match toggle {
let changed = match toggle {
Toggle::Diameter(index, value) if index < 2 => {
self.tolerances[index].diameter = value
let changed = self.tolerances[index].diameter != value;
self.tolerances[index].diameter = value;
changed
}
Toggle::ProjectedZone(value) => self.projected_zone = value,
_ => {}
Toggle::ProjectedZone(value) => {
let changed = self.projected_zone != value;
self.projected_zone = value;
changed
}
_ => false,
};
self.dirty |= changed;
}
pub fn is_valid(&self) -> bool {
!self.symbol.is_empty()
|| !self.symbol_tail.is_empty()
|| self.tolerances.iter().any(|entry| !entry.value.trim().is_empty())
|| self.datums.iter().any(|entry| !entry.value.trim().is_empty())
|| !self.projected_height.trim().is_empty()
|| !self.datum_identifier.trim().is_empty()
|| self.extra_rows.iter().any(|row| !row.trim().is_empty())
}
pub fn to_text(&self) -> String {
if !self.dirty {
if let Some(original) = &self.original_text {
return original.clone();
}
}
let only_plain_value = self.plain_frame
&& self.symbol.is_empty()
&& self.symbol_tail.is_empty()
&& self.tolerances[1] == ToleranceEntry::default()
&& self.datums.iter().all(|entry| *entry == DatumEntry::default());
let frame = if only_plain_value {
self.tolerances[0].value.clone()
} else {
let mut cells = Vec::with_capacity(6);
cells.push(escape(&self.symbol));
cells.push(format!("{}{}", escape(&self.symbol), self.symbol_tail));
for entry in &self.tolerances {
let mut value = String::new();
if entry.diameter && !entry.value.trim().is_empty() {
value.push_str(&escape("n"));
}
value.push_str(entry.value.trim());
value.push_str(&entry.value);
if !entry.value.trim().is_empty() {
value.push_str(&escape(&entry.material));
}
cells.push(value);
}
for entry in &self.datums {
let mut value = entry.value.trim().to_string();
if !value.is_empty() {
let mut value = entry.value.clone();
if !value.trim().is_empty() {
value.push_str(&escape(&entry.material));
}
cells.push(value);
@ -273,9 +358,11 @@ impl State {
while cells.last().is_some_and(String::is_empty) {
cells.pop();
}
let mut rows = vec![cells.join("%%v")];
cells.join("%%v")
};
let mut rows = vec![frame];
if !self.projected_height.trim().is_empty() || self.projected_zone {
let mut projected = self.projected_height.trim().to_string();
let mut projected = self.projected_height.clone();
if self.projected_zone {
projected.push_str(&escape("p"));
}
@ -285,8 +372,9 @@ impl State {
if rows.len() == 1 {
rows.push(String::new());
}
rows.push(self.datum_identifier.trim().to_string());
rows.push(self.datum_identifier.clone());
}
rows.extend(self.extra_rows.iter().cloned());
rows.join("\n")
}
}