fix(dimension): harden jogged radius workflow

This commit is contained in:
Hakan Seven 2026-08-27 08:09:02 +03:00
commit bc750c6d76
71 changed files with 5830 additions and 856 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=788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed#788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed"
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 = "788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed", 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 = "788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed" }
[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

@ -66,6 +66,13 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
"DIMARC" => {
use crate::modules::annotate::arc_length_dim::ArcLengthDimensionCommand;
let new_cmd = ArcLengthDimensionCommand::new();
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
"DIMORDINATE" => {
use crate::modules::annotate::ordinate_dim::OrdinateDimCommand;
let new_cmd = OrdinateDimCommand::new();
@ -605,10 +612,7 @@ impl OpenCADStudio {
}
"TOLERANCE" => {
use crate::modules::annotate::tolerance_cmd::ToleranceCommand;
let cmd = ToleranceCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
self.open_tolerance_dialog(None);
}
"TABLE" => {

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

@ -22,6 +22,7 @@ pub(crate) mod settings;
mod shortcuts;
mod style_ops;
mod text_inline;
mod tolerance_dialog;
mod update;
mod view;
mod visibility;
@ -399,6 +400,8 @@ pub(super) struct OpenCADStudio {
layer_translator: Option<crate::ui::window::layer_translator::State>,
/// Working copy of the Drawing Units dialog; `None` while it is closed.
drawing_units: Option<crate::ui::window::drawing_units::State>,
/// Working copy of the structured feature-control-frame editor.
geometric_tolerance: Option<crate::ui::window::geometric_tolerance::State>,
/// PICKDRAG (#226): false (default) = press-drag lassoes; true =
/// press-drag draws a rectangle marquee.
pick_drag_rect: bool,
@ -691,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>,
@ -1588,6 +1595,7 @@ pub enum ModalKind {
LayerStateManager,
LayerTranslator,
DrawingUnits,
GeometricTolerance,
DraftingSettings,
LayerStateEditor,
Plot,
@ -2318,6 +2326,14 @@ pub enum Message {
DrawingUnitsField(crate::ui::window::drawing_units::Field),
/// Drawing Units OK — write the working copy into the drawing.
DrawingUnitsApply,
/// One structured feature-control-frame field changed.
ToleranceDialogField(crate::ui::window::geometric_tolerance::Field),
/// One structured feature-control-frame option changed.
ToleranceDialogToggle(crate::ui::window::geometric_tolerance::Toggle),
/// Apply edits without closing the structured editor.
ToleranceDialogApply,
/// Commit edits or continue to insertion-point placement.
ToleranceDialogOk,
/// Toggle the Isolate pill's action menu open/closed.
ToggleIsolatePopup,
/// Close the Isolate action menu.
@ -2655,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),
@ -3145,6 +3186,7 @@ impl OpenCADStudio {
last_layer_translation: None,
layer_translator: None,
drawing_units: None,
geometric_tolerance: None,
pick_drag_rect: false,
perf_hud: false,
cycle_candidates: None,
@ -3246,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

@ -2,6 +2,7 @@ use super::helpers::{entity_type_key, entity_type_label, title_case_word};
use super::{OpenCADStudio, VARIES_LABEL};
use crate::io::linetypes;
use crate::scene::view::dispatch;
use crate::scene::model::object::PropValue;
use crate::ui;
use crate::t;
use acadrust::types::{Transform, Vector3};
@ -1317,11 +1318,88 @@ impl OpenCADStudio {
// Feature-control frame: FCF text style is the dimension
// style's DIMTXSTY.
acadrust::EntityType::Tolerance(tol) => {
if let Some(ds) = find_dim_style(doc, &tol.dimension_style_name) {
if !ds.dimtxsty.is_empty() {
set_row(&mut sections, "tol_text_style", ds.dimtxsty.clone());
}
use crate::entities::dim_override as dov;
let style = crate::entities::tolerance::resolve_dim_style(tol, doc);
let style_name = style
.map(|entry| entry.name.clone())
.unwrap_or_else(|| {
if tol.dimension_style_name.trim().is_empty() {
"Standard".to_string()
} else {
tol.dimension_style_name.clone()
}
});
let mut dim_style_names: Vec<String> = doc
.dim_styles
.iter()
.map(|entry| entry.name.clone())
.filter(|name| !name.trim().is_empty())
.collect();
if !dim_style_names
.iter()
.any(|name| name.eq_ignore_ascii_case(&style_name))
{
dim_style_names.push(style_name.clone());
}
set_row_value(
&mut sections,
"tol_dim_style",
PropValue::Choice {
selected: style_name,
options: dim_style_names,
},
);
let text_style_name = dov::handle(
&tol.common.extended_data,
dov::DIMTXSTY,
)
.and_then(|handle| {
doc.text_styles
.iter()
.find(|entry| entry.handle == handle)
})
.map(|entry| entry.name.clone())
.or_else(|| style.map(|entry| entry.dimtxsty.clone()))
.unwrap_or_else(|| "Standard".to_string());
let text_height_editable = doc
.text_styles
.iter()
.find(|entry| {
entry.name.eq_ignore_ascii_case(&text_style_name)
})
.is_none_or(|entry| !entry.has_fixed_height());
let mut names = text_style_names.clone();
if !names
.iter()
.any(|name| name.eq_ignore_ascii_case(&text_style_name))
{
names.push(text_style_name.clone());
}
set_row_value(
&mut sections,
"tol_text_style",
PropValue::Choice {
selected: text_style_name,
options: names,
},
);
let height = dov::real(
&tol.common.extended_data,
dov::DIMTXT,
)
.or_else(|| style.map(|entry| entry.dimtxt))
.unwrap_or(tol.text_height);
set_row_value(
&mut sections,
"tol_text_height",
if text_height_editable {
PropValue::EditText(format!("{height:.4}"))
} else {
PropValue::ReadOnly(format!("{height:.4}"))
},
);
}
// MultiLeader: max points + segment-angle constraints
// are MLeaderStyle settings, not stored on the entity.
@ -1712,7 +1790,7 @@ impl OpenCADStudio {
{
// Which entities show an Annotative row, the field it uses,
// and — for those that don't already carry the row
// (dimension / table) — the existing field to insert it
// (dimension / table / tolerance) — the existing field to insert it
// after. MLeader uses its editable toggle field.
let anno: Option<(&str, Option<&str>)> = match entity {
acadrust::EntityType::Text(_)
@ -1726,14 +1804,33 @@ impl OpenCADStudio {
acadrust::EntityType::Dimension(_) => {
Some(("annotative", Some("style_name")))
}
acadrust::EntityType::Tolerance(_) => {
Some(("annotative", Some("tol_dim_style")))
}
acadrust::EntityType::Table(_) => {
Some(("annotative", Some("tbl_style_handle")))
}
_ => None,
};
if let Some((anno_field, insert_after)) = anno {
let is_anno = crate::scene::annotative::is_annotative(doc, entity);
// Dimensions/tables carry no Annotative row yet — add one
let is_anno = crate::scene::annotative::is_annotative(doc, entity)
|| match entity {
acadrust::EntityType::Dimension(
acadrust::entities::Dimension::Arc(dimension),
) => {
crate::scene::annotative::dim_style_is_annotative(
doc,
&dimension.base.style_name,
)
}
acadrust::EntityType::Tolerance(_) => {
crate::scene::annotative::annotation_style_is_annotative(
doc, entity,
)
}
_ => false,
};
// Dimensions/tables/tolerances carry no Annotative row yet — add one
// right after their style row.
if let Some(anchor) = insert_after {
insert_row_after(
@ -1761,6 +1858,13 @@ impl OpenCADStudio {
value: t.is_annotative,
},
),
acadrust::EntityType::Dimension(
acadrust::entities::Dimension::Arc(_),
) => set_row(
&mut sections,
"annotative",
if is_anno { "Yes" } else { "No" }.to_string(),
),
acadrust::EntityType::Text(_)
| acadrust::EntityType::Insert(_)
| acadrust::EntityType::Hatch(_)
@ -1806,6 +1910,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,
}
@ -145,6 +148,13 @@ impl super::OpenCADStudio {
if self.tabs[i].scene.is_layer_locked(target) {
return iced::Task::none();
}
if matches!(
self.tabs[i].scene.document.get_entity(target),
Some(EntityType::Tolerance(_))
) {
self.open_tolerance_dialog(Some(target));
return iced::Task::none();
}
// Snapshot what we need before borrowing `self` mutably to open.
let Some(entity) = self.tabs[i].scene.document.get_entity(target) else {
return iced::Task::none();
@ -166,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))
}
}
@ -183,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;
@ -193,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 {
@ -229,12 +241,18 @@ impl super::OpenCADStudio {
} else {
crate::command::WorkingPlane::default()
};
let position = plane.to_local(ed.pos);
let mut t = Text::with_value(
&ed.value,
Vector3::new(position.x, position.y, position.z),
)
.with_height(ed.height);
let command_creation = ed.creation.is_some();
let mut t = if let Some(mut prepared) = ed.creation {
prepared.value = ed.value.clone();
prepared
} else {
let position = plane.to_local(ed.pos);
Text::with_value(
&ed.value,
Vector3::new(position.x, position.y, position.z),
)
.with_height(ed.height)
};
// New text inherits the document's current text style (STYLE), not
// the entity default. See #92.
let cur_style = self.tabs[i]
@ -244,24 +262,48 @@ impl super::OpenCADStudio {
.current_text_style_name
.clone();
if !cur_style.is_empty() {
t.style = cur_style;
}
let annotative = crate::scene::annotative::text_style_is_annotative(
&self.tabs[i].scene.document,
&t.style,
);
self.push_undo_snapshot(i, "TEXT");
let handle = self.commit_entity_handle(plane.place_entity(EntityType::Text(t)));
if annotative {
let scale = self.tabs[i].scene.current_annotation_scale_handle();
if let (Some(handle), Some(scale)) = (handle, scale) {
crate::scene::annotative::create_annotation_context(
&mut self.tabs[i].scene.document,
handle,
scale,
);
if t.style.trim().is_empty() {
t.style = cur_style;
}
}
if command_creation {
let annotation_multiplier = if crate::scene::annotative::text_style_is_annotative(
&self.tabs[i].scene.document,
&t.style,
) {
self.tabs[i].scene.creation_annotation_multiplier()
} else {
1.0
};
let display_height = crate::entities::text::text_run_placement_at_scale(
&t,
&self.tabs[i].scene.document,
annotation_multiplier as f32,
)
.height as f64
* annotation_multiplier;
if let Some(command) = self.tabs[i].suspended_cmd.as_mut() {
command.on_editor_display_height(display_height);
}
}
self.push_undo_snapshot(i, "TEXT");
self.tabs[i].scene.document.header.current_text_style_name = t.style.clone();
let variable_height = self.tabs[i]
.scene
.document
.text_styles
.iter()
.find(|style| style.name.eq_ignore_ascii_case(&t.style))
.is_none_or(|style| style.height <= 1.0e-9);
if variable_height
&& !matches!(
t.horizontal_alignment,
acadrust::entities::TextHorizontalAlignment::Aligned
)
{
self.tabs[i].scene.document.header.text_height = t.height;
}
let _ = self.commit_entity_handle(plane.place_entity(EntityType::Text(t)));
self.tabs[i].dirty = true;
}
self.refresh_properties();

108
src/app/tolerance_dialog.rs Normal file
View file

@ -0,0 +1,108 @@
use acadrust::{EntityType, Handle};
impl super::OpenCADStudio {
pub(super) fn open_tolerance_dialog(&mut self, editing: Option<Handle>) {
let i = self.active_tab;
if editing.is_some_and(|handle| self.tabs[i].scene.is_layer_locked(handle)) {
return;
}
let text = editing
.and_then(|handle| self.tabs[i].scene.document.get_entity(handle))
.and_then(|entity| match entity {
EntityType::Tolerance(tolerance) => Some(tolerance.text.as_str()),
_ => None,
})
.unwrap_or_default();
self.geometric_tolerance = Some(
crate::ui::window::geometric_tolerance::State::from_text(editing, text),
);
self.active_modal = Some(super::ModalKind::GeometricTolerance);
self.modal_offset = iced::Vector::ZERO;
self.modal_resize = iced::Vector::ZERO;
self.modal_content_size = None;
self.modal_drag_last = None;
self.modal_dragging = false;
self.modal_resizing = false;
}
pub(super) fn apply_tolerance_dialog_edit(&mut self) -> bool {
let Some(state) = self.geometric_tolerance.as_ref() else {
return false;
};
let Some(handle) = state.editing else {
return false;
};
if !state.is_valid() || self.tabs[self.active_tab].scene.is_layer_locked(handle) {
return false;
}
let text = state.to_text();
let i = self.active_tab;
let unchanged = self.tabs[i]
.scene
.document
.get_entity(handle)
.is_some_and(|entity| {
matches!(entity, EntityType::Tolerance(tolerance) if tolerance.text == text)
});
if unchanged {
return true;
}
self.push_undo_snapshot(i, "TOLERANCE");
let Some(EntityType::Tolerance(tolerance)) =
self.tabs[i].scene.document.get_entity_mut(handle)
else {
return false;
};
tolerance.text = text;
self.tabs[i]
.scene
.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]);
self.tabs[i].dirty = true;
self.refresh_properties();
true
}
pub(super) fn begin_tolerance_placement(&mut self) -> bool {
let Some(state) = self.geometric_tolerance.take() else {
return false;
};
if !state.is_valid() || state.editing.is_some() {
self.geometric_tolerance = Some(state);
return false;
}
let i = self.active_tab;
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()
} else {
crate::command::WorkingPlane::default()
};
command.set_working_plane(plane);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
self.active_modal = None;
true
}
}

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,
@ -1901,7 +1906,73 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
}
self.push_undo_snapshot(i, "CHPROP");
if field.starts_with("dim_") {
if field == "tol_text_style" {
use crate::entities::dim_override as dov;
use acadrust::xdata::XDataValue;
let style_handle = self.tabs[i]
.scene
.document
.text_styles
.iter()
.find(|entry| entry.name.eq_ignore_ascii_case(&value))
.map(|entry| entry.handle);
if let Some(style_handle) = style_handle {
for &handle in &handles {
if self.tabs[i].scene.is_layer_locked(handle)
|| !matches!(
self.tabs[i].scene.document.get_entity(handle),
Some(acadrust::EntityType::Tolerance(_))
)
{
continue;
}
dov::set(
&mut self.tabs[i].scene.document,
handle,
dov::DIMTXSTY,
Some(XDataValue::Handle(style_handle)),
);
}
}
} else if field == "tol_dim_style" {
let style = self.tabs[i]
.scene
.document
.dim_styles
.iter()
.find(|entry| entry.name.eq_ignore_ascii_case(&value))
.map(|entry| (entry.handle, entry.name.clone(), entry.annotative));
if let Some((style_handle, style_name, annotative)) = style {
let scale = self.tabs[i].scene.creation_annotation_scale_handle();
for &handle in &handles {
if self.tabs[i].scene.is_layer_locked(handle) {
continue;
}
if let Some(acadrust::EntityType::Tolerance(tolerance)) =
self.tabs[i].scene.document.get_entity_mut(handle)
{
tolerance.dimension_style_handle = Some(style_handle);
tolerance.dimension_style_name = style_name.clone();
} else {
continue;
}
crate::scene::annotative::set_entity_annotative(
&mut self.tabs[i].scene.document,
handle,
annotative,
);
if annotative {
if let Some(scale) = scale {
crate::scene::annotative::create_annotation_context(
&mut self.tabs[i].scene.document,
handle,
scale,
);
}
}
}
}
} else if field.starts_with("dim_") {
for &handle in &handles {
if self.tabs[i].scene.is_layer_locked(handle) {
continue;
@ -2552,6 +2623,27 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
continue;
}
match field {
"tol_text_height" => {
use crate::entities::dim_override as dov;
let trimmed = val.trim();
if trimmed.is_empty() {
dov::set(
&mut self.tabs[i].scene.document,
handle,
dov::DIMTXT,
None,
);
} else if let Ok(height) = trimmed.parse::<f64>() {
if height > 0.0 {
dov::set(
&mut self.tabs[i].scene.document,
handle,
dov::DIMTXT,
Some(acadrust::xdata::XDataValue::Real(height)),
);
}
}
}
_ if field.starts_with("dim_") => {
if matches!(
self.tabs[i].scene.document.get_entity(handle),

View file

@ -195,6 +195,7 @@ impl OpenCADStudio {
self.attr_editor_selected = 0;
self.attr_editor_tab = crate::ui::window::attribute_editor::AttrTab::Attribute;
}
Some(GeometricTolerance) => self.geometric_tolerance = None,
// Closing (✕) discards edits made since the last Apply — matching the
// style editors. Committing happens only through the Apply button.
Some(Aliases) => self.alias_editor_rows.clear(),
@ -234,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);
@ -278,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).
@ -3706,6 +3731,40 @@ impl OpenCADStudio {
self.refresh_properties();
Task::none()
}
Message::ToleranceDialogField(field) => {
if let Some(state) = self.geometric_tolerance.as_mut() {
state.apply_field(field);
}
Task::none()
}
Message::ToleranceDialogToggle(toggle) => {
if let Some(state) = self.geometric_tolerance.as_mut() {
state.apply_toggle(toggle);
}
Task::none()
}
Message::ToleranceDialogApply => {
self.apply_tolerance_dialog_edit();
Task::none()
}
Message::ToleranceDialogOk => {
let editing = self
.geometric_tolerance
.as_ref()
.and_then(|state| state.editing)
.is_some();
if editing {
if self.apply_tolerance_dialog_edit() {
self.geometric_tolerance = None;
self.active_modal = None;
self.reset_modal_geometry();
}
} else {
self.begin_tolerance_placement();
self.reset_modal_geometry();
}
Task::none()
}
Message::SetLinearFormat(code) => {
self.units_popup_open = false;
let i = self.active_tab;
@ -3880,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) => {
@ -3915,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

@ -21,6 +21,7 @@ impl OpenCADStudio {
Some(K::LayerStateManager) => crate::tr!("modal", "layer-state-manager"),
Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(),
Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(),
Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(),
Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(),
Some(K::LayerStateEditor) => crate::tr!("modal", "edit-layer-state"),
Some(K::Plot) => crate::tr!("modal", "plot"),
@ -234,6 +235,12 @@ impl OpenCADStudio {
crate::ui::window::drawing_units::view_window(state, flow)
})
}
super::super::ModalKind::GeometricTolerance => {
let state = self.geometric_tolerance.as_ref()?;
sized_flow(ex, 670, 530, |flow| {
crate::ui::window::geometric_tolerance::view_window(state, flow)
})
}
super::super::ModalKind::LayerStateManager => {
let states = self.tabs[self.active_tab].scene.document.layer_states();
sized_flow(

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

@ -286,6 +286,7 @@ pub fn property_int_code(field: &str) -> Option<i16> {
"dim_tolerance_precision" => DIMTDEC,
"dim_tolerance_pos_vert" => DIMTOLJ,
"dim_tolerance_alignment" => DIMTALN,
"dim_arc_symbol" => DIMARCSYM,
_ => return None,
})
}
@ -337,6 +338,7 @@ fn inherited_int(doc: &CadDocument, handle: Handle, code: i16) -> i16 {
DIMADEC => style.dimadec,
DIMTFILL => style.dimtfill,
DIMTALN => 0,
DIMARCSYM => style.dimarcsym,
_ => 0,
}
}
@ -703,6 +705,12 @@ pub fn set_property(
"move text, no leader" => Some(2),
_ => trimmed.parse().ok(),
},
"dim_arc_symbol" => match trimmed.to_ascii_lowercase().as_str() {
"preceding dimension text" => Some(0),
"above dimension text" => Some(1),
"none" => Some(2),
_ => trimmed.parse().ok(),
},
"dim_alt_format" => match trimmed.to_ascii_lowercase().as_str() {
"scientific" => Some(1),
"decimal" => Some(2),

View file

@ -146,6 +146,11 @@ fn properties(dim: &Dimension) -> Vec<PropSection> {
"jog_angle",
d.jog_angle.to_degrees(),
),
ro(
t!("Measurement").as_ref(),
"measurement",
format!("{:.4}", d.measurement()),
),
],
}];
}
@ -216,6 +221,16 @@ fn properties(dim: &Dimension) -> Vec<PropSection> {
],
}];
}
if let Dimension::Arc(d) = dim {
return vec![PropSection {
title: t!("Misc").into_owned(),
props: vec![Property {
label: t!("Dimension style").into_owned(),
field: "style_name",
value: PropValue::PlainText(d.base.style_name.clone()),
}],
}];
}
let mut props = base_props(dim.base());
match dim {
Dimension::Aligned(d) => {
@ -2638,6 +2653,33 @@ pub fn style_sections(
}
}
if matches!(dimension, Dimension::Arc(_)) {
if let Some(lines) = sections
.iter_mut()
.find(|section| section.title == t!("Lines & Arrows").as_ref())
{
let symbol = match int(ov::DIMARCSYM, s.dimarcsym) {
1 => "Above dimension text",
2 => "None",
_ => "Preceding dimension text",
};
lines.props.insert(
3,
choice(
t!("Arc length symbol").as_ref(),
"dim_arc_symbol",
symbol,
&[
"Preceding dimension text",
"Above dimension text",
"None",
],
true,
),
);
}
}
if matches!(dimension, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) {
if let Some(text_section) = sections
.iter_mut()
@ -2914,7 +2956,7 @@ use crate::scene::convert::tessellate::{
add_polyline, add_segment, append_arrow, arrow_from_block_with_deferred_hatch,
normalized_or, ArrowKind, DimGeom,
};
use crate::scene::model::wire_model::{SnapHint, WireModel};
use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel};
fn apply_dimension_breaks(
document: &CadDocument,
@ -3078,6 +3120,50 @@ fn tessellate_dimension_inner(
view_aabb: Option<[f32; 4]>,
world_per_pixel: Option<f32>,
) -> Vec<WireModel> {
if let Dimension::Arc(arc) = dim {
if let Some((local_arc, normal)) = arc_dimension_in_ocs(arc) {
let mut wires = tessellate_dimension_inner(
document,
handle,
&Dimension::Arc(local_arc),
selected,
entity_color,
line_weight_px,
anno_scale,
selected_set,
active_viewport,
bg_color,
None,
world_per_pixel,
);
for wire in &mut wires {
map_wire_ocs_to_wcs(wire, normal);
}
return wires;
}
}
if let Dimension::LargeRadial(radial) = dim {
if let Some((local_radial, normal)) = large_radial_dimension_in_ocs(radial) {
let mut wires = tessellate_dimension_inner(
document,
handle,
&Dimension::LargeRadial(local_radial),
selected,
entity_color,
line_weight_px,
anno_scale,
selected_set,
active_viewport,
bg_color,
None,
world_per_pixel,
);
for wire in &mut wires {
map_wire_ocs_to_wcs(wire, normal);
}
return wires;
}
}
let name = handle.value().to_string();
// (Baked-block fast path moved up into scene::tessellate_entity so the
// recursive call goes through the LOD ladder, not the kernel path.)
@ -3445,9 +3531,6 @@ fn tessellate_dimension_inner(
// DIMUPT governs interactive creation-time text placement; saved
// geometry already carries the resulting position.
let _ = s.dimupt;
// Per-entity arc-symbol and jog-angle values are consumed by their
// native dimension variants; the resolved style values remain the
// creation defaults.
let _ = (s.dimarcsym, s.dimjogang);
// DIMUNIT is the obsolete pre-R2000 linear unit format; DIMLUNIT
// supersedes it. Read but not honoured.
@ -3674,6 +3757,46 @@ fn tessellate_dimension_inner(
fill_tris_low: Vec::new(),
});
if let Some(symbol) = style.and_then(|style| {
arc_length_symbol_points(dim, Some(style), dim_txt, dim_scale, style.dimarcsym)
}) {
let mut points = Vec::new();
add_polyline(&mut points, &symbol);
wires.push(WireModel {
point_marker: None,
taper_widths: Vec::new(),
pattern_stations: Vec::new(),
world_width: 0.0,
depth_override: None,
display_visible: true,
plot_visible: true,
fill_is_3d: false,
fill_is_2d_solid: false,
render_instance: None,
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points,
points_low: Vec::new(),
color: if selected { WireModel::SELECTED } else { text_color },
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
}
// DIMTFILL: 0=none, 1=drawing background (mask), 2=DIMTFILLCLR.
if let Some(s) = style {
if s.dimtfill == 1 || s.dimtfill == 2 {
@ -3975,6 +4098,57 @@ fn text_fill_rect(
let p4 = corner(-hx, hy);
Some(vec![p1, p2, p3, p1, p3, p4])
}
fn arc_length_symbol_points(
dim: &Dimension,
style: Option<&DimStyle>,
text_height: f64,
dim_scale: f64,
symbol_position: i16,
) -> Option<Vec<Vec3>> {
if !matches!(dim, Dimension::Arc(_)) || symbol_position == 2 {
return None;
}
let value = dimension_text_value(dim, style)?;
if value.is_empty() || text_height <= 1.0e-12 {
return None;
}
let position = dimension_text_pos_f64(dim, style, text_height, dim_scale);
let rotation = dimension_text_rotation(dim, style);
let (sin_rotation, cos_rotation) = rotation.sin_cos();
let text_width = value.chars().count() as f64 * text_height * 0.6;
let symbol_width = text_height * 0.62;
let symbol_height = text_height * 0.20;
let (center_x, center_y) = if symbol_position == 1 {
(0.0, text_height * 0.72)
} else {
(-(text_width * 0.5 + symbol_width * 0.70), text_height * 0.02)
};
let transform = |x: f64, y: f64| {
let local_x = center_x + x;
let local_y = center_y + y;
Vec3::new(
(position.x + local_x * cos_rotation - local_y * sin_rotation) as f32,
(position.y + local_x * sin_rotation + local_y * cos_rotation) as f32,
position.z as f32,
)
};
let steps = 8usize;
Some(
(0..=steps)
.map(|index| {
let t = index as f64 / steps as f64;
let x = (t - 0.5) * symbol_width;
let normalized = x / (symbol_width * 0.5);
let y = symbol_height * (1.0 - normalized * normalized);
transform(x, y)
})
.collect(),
)
}
struct SuppressFlags {
ext1: bool,
ext2: bool,
@ -4210,6 +4384,7 @@ fn dimension_geometry(
}
}
Dimension::Arc(d) => {
let explicit_sweep = arc_dimension_angles(d);
append_angular_dimension(
&mut g,
lv(d.center_point),
@ -4218,10 +4393,7 @@ fn dimension_geometry(
lv(d.definition_point),
arrow1,
arrow2,
d.is_partial.then_some((
d.arc_start_parameter as f32,
d.arc_end_parameter as f32,
)),
explicit_sweep,
params,
suppress,
);
@ -4633,6 +4805,195 @@ fn two_line_angle_frame(
Some((vertex, start, end))
}
pub(crate) fn arc_dimension_angles(dimension: &DimensionArc) -> Option<(f32, f32)> {
let raw = dimension.arc_end_parameter - dimension.arc_start_parameter;
let mut sweep = raw.rem_euclid(std::f64::consts::TAU);
if sweep <= 1.0e-12 && raw.abs() > 1.0e-12 {
sweep = std::f64::consts::TAU;
}
if sweep > 1.0e-12 {
let start = dimension.arc_start_parameter as f32;
return Some((start, start + sweep as f32));
}
let start = (dimension.first_extension_point.y - dimension.center_point.y)
.atan2(dimension.first_extension_point.x - dimension.center_point.x);
let end = (dimension.second_extension_point.y - dimension.center_point.y)
.atan2(dimension.second_extension_point.x - dimension.center_point.x);
let sweep = (end - start).rem_euclid(std::f64::consts::TAU);
(sweep > 1.0e-12).then_some((start as f32, (start + sweep) as f32))
}
pub(crate) fn arc_dimension_in_ocs(
dimension: &DimensionArc,
) -> Option<(DimensionArc, Vector3)> {
let length = dimension.base.normal.length();
if !length.is_finite() || length <= 1.0e-12 {
return None;
}
let normal = dimension.base.normal / length;
if (normal - Vector3::UNIT_Z).length() <= 1.0e-12 {
return None;
}
let normal_tuple = (normal.x, normal.y, normal.z);
let to_ocs = |point: Vector3| {
let point = crate::scene::view::transform::wcs_point_to_ocs(
(point.x, point.y, point.z),
normal_tuple,
);
Vector3::new(point.0, point.1, point.2)
};
let mut local = dimension.clone();
local.center_point = to_ocs(dimension.center_point);
let elevation = local.center_point.z;
let on_plane = |point: Vector3| {
let mut point = to_ocs(point);
point.z = elevation;
point
};
local.definition_point = on_plane(dimension.definition_point);
local.first_extension_point = on_plane(dimension.first_extension_point);
local.second_extension_point = on_plane(dimension.second_extension_point);
local.first_leader_point = on_plane(dimension.first_leader_point);
local.second_leader_point = on_plane(dimension.second_leader_point);
local.base.definition_point = local.definition_point;
local.base.text_middle_point = on_plane(dimension.base.text_middle_point);
local.base.insertion_point = on_plane(dimension.base.insertion_point);
local.base.normal = Vector3::UNIT_Z;
Some((local, normal))
}
pub(crate) fn large_radial_dimension_in_ocs(
dimension: &DimensionLargeRadial,
) -> Option<(DimensionLargeRadial, Vector3)> {
let length = dimension.base.normal.length();
if !length.is_finite() || length <= 1.0e-12 {
return None;
}
let normal = dimension.base.normal / length;
if (normal - Vector3::UNIT_Z).length() <= 1.0e-12 {
return None;
}
let normal_tuple = (normal.x, normal.y, normal.z);
let to_ocs = |point: Vector3| {
let point = crate::scene::view::transform::wcs_point_to_ocs(
(point.x, point.y, point.z),
normal_tuple,
);
Vector3::new(point.0, point.1, point.2)
};
let mut local = dimension.clone();
local.definition_point = to_ocs(dimension.definition_point);
let elevation = local.definition_point.z;
let on_plane = |point: Vector3| {
let mut point = to_ocs(point);
point.z = elevation;
point
};
local.chord_point = on_plane(dimension.chord_point);
local.override_center = on_plane(dimension.override_center);
local.jog_point = on_plane(dimension.jog_point);
local.base.definition_point = local.definition_point;
local.base.text_middle_point = on_plane(dimension.base.text_middle_point);
local.base.insertion_point = on_plane(dimension.base.insertion_point);
local.base.normal = Vector3::UNIT_Z;
Some((local, normal))
}
fn map_wire_ocs_to_wcs(wire: &mut WireModel, normal: Vector3) {
let normal_tuple = (normal.x, normal.y, normal.z);
let map = |x: f64, y: f64, z: f64| {
crate::scene::view::transform::ocs_point_to_wcs((x, y, z), normal_tuple)
};
let map_split = |high: &mut Vec<[f32; 3]>, low: &mut Vec<[f32; 3]>| {
let mut mapped_high = Vec::with_capacity(high.len());
let mut mapped_low = Vec::with_capacity(high.len());
for (index, point) in high.iter().enumerate() {
if point[0].is_nan() {
mapped_high.push(*point);
mapped_low.push([0.0; 3]);
continue;
}
let residual = low.get(index).copied().unwrap_or([0.0; 3]);
let point = map(
point[0] as f64 + residual[0] as f64,
point[1] as f64 + residual[1] as f64,
point[2] as f64 + residual[2] as f64,
);
let (xh, xl) = WireModel::split_ds(point.0);
let (yh, yl) = WireModel::split_ds(point.1);
let (zh, zl) = WireModel::split_ds(point.2);
mapped_high.push([xh, yh, zh]);
mapped_low.push([xl, yl, zl]);
}
*high = mapped_high;
*low = mapped_low;
};
map_split(&mut wire.points, &mut wire.points_low);
map_split(&mut wire.fill_tris, &mut wire.fill_tris_low);
map_split(&mut wire.pick_tris, &mut wire.pick_tris_low);
wire.text_verts = crate::scene::model::wire_model::map_text_verts(
&wire.text_verts,
map,
);
for (point, _) in &mut wire.snap_pts {
let mapped = map(point.x, point.y, point.z);
*point = DVec3::new(mapped.0, mapped.1, mapped.2);
}
for point in &mut wire.key_vertices {
let mapped = map(point[0], point[1], point[2]);
*point = [mapped.0, mapped.1, mapped.2];
}
if let Some(marker) = &mut wire.point_marker {
let origin = map(marker.origin.x, marker.origin.y, marker.origin.z);
let vector = |value: DVec3| {
let mapped = map(value.x, value.y, value.z);
DVec3::new(mapped.0, mapped.1, mapped.2)
};
marker.origin = DVec3::new(origin.0, origin.1, origin.2);
marker.normal = vector(marker.normal).normalize_or(DVec3::Z);
marker.axis_x = vector(marker.axis_x).normalize_or(DVec3::X);
marker.axis_y = vector(marker.axis_y).normalize_or(DVec3::Y);
}
for tangent in &mut wire.tangent_geoms {
match tangent {
TangentGeom::Line { p1, p2 } => {
let first = map(p1[0] as f64, p1[1] as f64, p1[2] as f64);
let second = map(p2[0] as f64, p2[1] as f64, p2[2] as f64);
*p1 = [first.0 as f32, first.1 as f32, first.2 as f32];
*p2 = [second.0 as f32, second.1 as f32, second.2 as f32];
}
TangentGeom::Circle { center, radius } => {
let center = map(
center[0] as f64,
center[1] as f64,
center[2] as f64,
);
let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes(normal_tuple);
*tangent = TangentGeom::PlanarCircle {
center: [center.0, center.1, center.2],
axis_x: [x_axis.0, x_axis.1, x_axis.2],
axis_y: [y_axis.0, y_axis.1, y_axis.2],
radius: *radius as f64,
};
}
TangentGeom::PlanarCircle { center, axis_x, axis_y, .. }
| TangentGeom::Arc { center, axis_x, axis_y, .. } => {
let mapped = map(center[0], center[1], center[2]);
let map_vector = |axis: [f64; 3]| {
let mapped = map(axis[0], axis[1], axis[2]);
[mapped.0, mapped.1, mapped.2]
};
*center = [mapped.0, mapped.1, mapped.2];
*axis_x = map_vector(*axis_x);
*axis_y = map_vector(*axis_y);
}
}
}
wire.aabb = WireModel::UNBOUNDED_AABB;
}
fn angular_dimension_frame(dim: &Dimension) -> Option<(Vec3, f32, f32, f32)> {
let (vertex, start, end, arc_point) = match dim {
Dimension::Angular2Ln(value) => {
@ -4659,6 +5020,12 @@ fn angular_dimension_frame(dim: &Dimension) -> Option<(Vec3, f32, f32, f32)> {
two_line_angle_frame(vertex, first, vertex, second, arc_point)?;
(vertex, start, end, arc_point)
}
Dimension::Arc(value) => {
let vertex = vec3_local(value.center_point);
let arc_point = vec3_local(value.definition_point);
let (start, end) = arc_dimension_angles(value)?;
(vertex, start, end, arc_point)
}
_ => return None,
};
let radius = vertex.distance(arc_point);
@ -5249,7 +5616,7 @@ fn dimension_text_natural_rotation(dim: &Dimension) -> f64 {
let dy = d.second_point.y - d.first_point.y;
dy.atan2(dx)
}
Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => angular_dimension_frame(dim)
Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) | Dimension::Arc(_) => angular_dimension_frame(dim)
.map(|(_, start, end, _)| {
((start + end) * 0.5 + std::f32::consts::FRAC_PI_2) as f64
})
@ -6183,6 +6550,45 @@ pub(crate) fn baked_dimension_text_entity(
Some(ent)
}
pub(crate) fn baked_arc_length_symbol_points(
dim: &Dimension,
document: &CadDocument,
anno_scale: f64,
) -> Vec<Vector3> {
if !matches!(dim, Dimension::Arc(_)) {
return Vec::new();
}
let style_name = dim.base().style_name.as_str();
let style = document.dim_styles.iter().find(|style| {
style.name.eq_ignore_ascii_case(style_name)
|| (style_name.trim().is_empty() && style.name.eq_ignore_ascii_case("Standard"))
});
let dim_scale = style
.map(|style| {
if style.dimscale > 1.0e-6 {
style.dimscale
} else {
anno_scale
}
})
.unwrap_or(1.0);
let text_height = style
.map(|style| style.dimtxt * dim_scale)
.unwrap_or(2.5 * dim_scale);
let symbol_position = crate::entities::dim_override::int(
&dim.base().common.extended_data,
crate::entities::dim_override::DIMARCSYM,
)
.or_else(|| style.map(|style| style.dimarcsym))
.unwrap_or(0);
arc_length_symbol_points(dim, style, text_height, dim_scale, symbol_position)
.unwrap_or_default()
.into_iter()
.map(|point| Vector3::new(point.x as f64, point.y as f64, point.z as f64))
.collect()
}
pub(crate) fn dimension_text_grip_position(
dim: &Dimension,
document: &CadDocument,

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,8 +3,10 @@ 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::model::object::{GripApply, GripDef, PropSection};
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;
use crate::scene::view::transform;
@ -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"))?;
@ -144,51 +146,6 @@ fn symbol_font_switch(inner: &str) -> Option<char> {
tail.chars().next()
}
/// Per-entity overrides of individual dimension-style variables, carried as
/// extended data under the `DSTYLE` application.
///
/// An entity may keep its style yet override single variables on itself, as
/// `(variable group code, value)` pairs. Reading the style but ignoring these
/// draws the frame at the style's size rather than its own — which is how this
/// one, overriding its text height to a fraction of the style's, came out far
/// too large.
///
/// Only the character height is read — it is the frame's single geometric
/// input (see `tessellate_tolerance`); every other variable is left to the style.
fn dstyle_overrides(tol: &Tolerance) -> Option<f64> {
// The only variable the frame is built from.
const DIMTXT: i16 = 140;
use acadrust::xdata::XDataValue as V;
// The record belongs to the shared "ACAD" application and names itself in
// its FIRST STRING VALUE. There is no record called "DSTYLE" — asking for
// one finds nothing and leaves every override silently unread.
let rec = tol.common.extended_data.get_record("ACAD")?;
let mut vals = rec.values.iter();
match vals.next() {
Some(V::String(s)) if s == "DSTYLE" => {}
_ => return None,
}
let mut txt = None;
let mut pending: Option<i16> = None;
for v in vals {
match v {
V::Integer16(code) => pending = Some(*code),
V::Real(value) | V::Distance(value) => {
if pending.take() == Some(DIMTXT) {
txt = Some(*value);
}
}
// Braces and anything else just delimit; a non-numeric value also
// ends the pair we were waiting on.
_ => pending = None,
}
}
txt
}
/// The tolerance's dimension style — by handle first, then by name.
///
/// The order matters and is not interchangeable: a DWG records the style's
@ -196,7 +153,7 @@ fn dstyle_overrides(tol: &Tolerance) -> Option<f64> {
/// leaves the handle empty. Matching on the name alone — the shape used
/// elsewhere for entities that only ever carry one — would silently resolve
/// every DWG-read tolerance to "Standard" and pick the wrong metrics.
fn resolve_dim_style<'a>(
pub(crate) fn resolve_dim_style<'a>(
tol: &Tolerance,
doc: &'a acadrust::CadDocument,
) -> Option<&'a acadrust::tables::DimStyle> {
@ -222,8 +179,20 @@ fn resolve_dim_style<'a>(
///
/// This is where the pen LANDS, so it carries the font's letter spacing past
/// the final glyph — which is what puts the gap between one run and the next.
fn run_advance(text: &str, font: &str, height: f32) -> f32 {
crate::entities::text_support::text_local_bounds(font, text, height, 1.0, 0.0)
fn run_advance(
text: &str,
font: &str,
height: f32,
width_factor: f32,
oblique: f32,
) -> f32 {
crate::entities::text_support::text_local_bounds(
font,
text,
height,
width_factor,
oblique,
)
.map(|b| b.advance)
.unwrap_or(0.0)
}
@ -232,8 +201,11 @@ fn run_advance(text: &str, font: &str, height: f32) -> f32 {
///
/// Glyph geometry is authored against a 9-unit cap height, so a font's spacing
/// scales with the character height like everything else.
fn letter_spacing(font: &str, height: f32) -> f32 {
crate::scene::text::font_face::Face::resolve(font).letter_spacing() * height / 9.0
fn letter_spacing(font: &str, height: f32, width_factor: f32) -> f32 {
crate::scene::text::font_face::Face::resolve(font).letter_spacing()
* height
/ 9.0
* width_factor
}
/// How wide a compartment's content actually draws.
@ -243,36 +215,56 @@ fn letter_spacing(font: &str, height: f32) -> f32 {
/// over-measures by exactly one spacing — the gaps between runs are real, the
/// one hanging off the end is not — and every compartment came out that much
/// too wide.
fn content_width(cell: &Cell, height: f32) -> f32 {
fn content_width(
cell: &Cell,
height: f32,
text_style: &crate::entities::text_support::ResolvedTextStyle,
) -> f32 {
let Some(last) = cell.last() else {
return 0.0;
};
let pen: f32 = cell
.iter()
.map(|r| run_advance(&r.text, r.font, height))
.map(|run| {
let (font, width, oblique) = if run.font == SYMBOL_FONT {
(SYMBOL_FONT, 1.0, 0.0)
} else {
(
text_style.font_name.as_str(),
text_style.width_factor,
text_style.oblique_angle,
)
};
run_advance(&run.text, font, height, width, oblique)
})
.sum();
(pen - letter_spacing(last.font, height)).max(0.0)
let (last_font, last_width) = if last.font == SYMBOL_FONT {
(SYMBOL_FONT, 1.0)
} else {
(text_style.font_name.as_str(), text_style.width_factor)
};
(pen - letter_spacing(last_font, height, last_width)).max(0.0)
}
/// 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: &'static str,
font: String,
origin: [f32; 2],
strokes: Vec<Vec<[f32; 2]>>,
height: f32,
rotation: f32,
width_factor: f32,
oblique: f32,
}
/// 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(
@ -296,17 +288,29 @@ fn tessellate_tolerance(
// and the frame draws about ten times too small. Falling back to them only
// when no style resolves keeps DXF-read entities working.
let style = resolve_dim_style(tol, doc);
let scale = style
.map(|s| if s.dimscale > 1e-6 { s.dimscale } else { 1.0 })
let xd = &tol.common.extended_data;
let scale = crate::entities::dim_override::real(xd, crate::entities::dim_override::DIMSCALE)
.or_else(|| style.map(|s| s.dimscale))
.map(|value| if value > 1e-6 { value } else { 1.0 })
.unwrap_or(1.0);
// The entity's own overrides win over its style; the style wins over the
// entity's constructed defaults.
let h = dstyle_overrides(tol)
let h = crate::entities::dim_override::real(xd, crate::entities::dim_override::DIMTXT)
.or(style.map(|s| s.dimtxt))
.map(|v| v * scale)
.unwrap_or(tol.text_height) as f32;
let h = if h > 1e-6 { h } else { 2.5_f32 };
let text_style_name = crate::entities::dim_override::handle(
xd,
crate::entities::dim_override::DIMTXSTY,
)
.and_then(|handle| doc.text_styles.iter().find(|entry| entry.handle == handle))
.map(|entry| entry.name.as_str())
.or_else(|| style.map(|style| style.dimtxsty.as_str()))
.unwrap_or("Standard");
let text_style = crate::entities::text_support::resolve_text_style(text_style_name, doc);
// A compartment is a PROPORTION of the character height: it spans -h..+h
// about the row's centreline, so it is exactly 2h tall and the h/2 margin
// falls out of that rather than being an input.
@ -329,19 +333,14 @@ fn tessellate_tolerance(
// (`len()`) would make each one two or three cells wide.
// A compartment's runs come from different fonts, so each is measured in its
// own before they are summed.
let cell_width = |cell: &Cell| -> f32 { (content_width(cell, h) + 2.0 * pad).max(min_cell_w) };
let cell_width = |cell: &Cell| -> f32 {
(content_width(cell, h, &text_style) + 2.0 * pad).max(min_cell_w)
};
let row_widths: Vec<Vec<f32>> = rows
.iter()
.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();
@ -364,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],
]);
}
@ -379,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.
@ -399,26 +396,41 @@ fn tessellate_tolerance(
// Centre the compartment's whole content, then lay its runs out
// left to right — each in its own font, each advancing the pen
// by what that font actually measures.
let mut run_x = cell_x + (cw - content_width(cell, h)) * 0.5;
let mut run_x = cell_x + (cw - content_width(cell, h, &text_style)) * 0.5;
for run in cell {
let (text, font) = (run.text.clone(), run.font);
let text = run.text.clone();
let (font, width_factor, oblique) = if run.font == SYMBOL_FONT {
(SYMBOL_FONT.to_string(), 1.0, 0.0)
} else {
(
text_style.font_name.clone(),
text_style.width_factor,
text_style.oblique_angle,
)
};
let (local_strokes, _) =
lff::tessellate_text_ex([0.0, 0.0], h, 0.0, 1.0, 0.0, font, &text);
// Glyph polylines rotated about the run's origin (no origin
// translation — the wire-builder adds `origin`).
lff::tessellate_text_ex(
[0.0, 0.0],
h,
0.0,
width_factor,
oblique,
&font,
&text,
);
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);
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,
});
run_x += advance;
}
@ -430,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 {
@ -446,7 +476,57 @@ 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>| {
index.filter(|value| !matches!(value, 0 | 256)).map(|value| {
let [red, green, blue, _] = crate::scene::convert::tess_util::aci_to_rgba(
&acadrust::types::Color::from_index(value),
);
[red, green, blue]
})
};
let color_value = |color: &acadrust::types::Color| {
let [red, green, blue, _] =
crate::scene::convert::tess_util::aci_to_rgba(color);
[red, green, blue]
};
let frame_color = if let Some(index) = crate::entities::dim_override::int(
xd,
crate::entities::dim_override::DIMCLRD,
) {
explicit_color(Some(index))
} else if let Some(color) =
style.and_then(|entry| entry.dimclrd_true_color.as_ref())
{
Some(color_value(color))
} else {
explicit_color(style.map(|entry| entry.dimclrd))
};
let text_color = if let Some(index) = crate::entities::dim_override::int(
xd,
crate::entities::dim_override::DIMCLRT,
) {
explicit_color(Some(index))
} else if let Some(color) =
style.and_then(|entry| entry.dimclrt_true_color.as_ref())
{
Some(color_value(color))
} else {
explicit_color(style.map(|entry| entry.dimclrt))
};
// Frame geometry first (run-less → always strokes; also the anchor
// group so its origin = the insertion point), then one run-group per
@ -456,9 +536,10 @@ impl RenderConvertible for Tolerance {
if !box_strokes.is_empty() {
groups.push(TextStroke {
strokes: box_strokes,
origin: ins,
color: None,
origin: [ins.x, ins.y],
color: frame_color,
fill_tris: vec![],
plane: Some(plane(ins)),
run: None,
});
}
@ -466,18 +547,23 @@ 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: None,
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.to_string(),
font: cell.font,
height: cell.height,
rotation: cell.rotation,
width_factor: 1.0,
oblique: 0.0,
rotation: 0.0,
width_factor: cell.width_factor,
oblique: cell.oblique,
// `tracking` scales the font's own letter spacing, so 0
// collapses the gap between glyphs and the characters run
// together. Every other text-bearing entity passes 1.0, and
@ -539,12 +625,19 @@ impl Grippable for Tolerance {
// ── PropertyEditable ──────────────────────────────────────────────────────────
impl PropertyEditable for Tolerance {
fn geometry_properties(&self, _text_style_names: &[String]) -> Vec<PropSection> {
fn geometry_properties(&self, text_style_names: &[String]) -> Vec<PropSection> {
vec![
PropSection {
title: t!("Text").into_owned(),
props: vec![
ro(t!("Text style").as_ref(), "tol_text_style", String::new()),
Property {
label: t!("Text style").into_owned(),
field: "tol_text_style",
value: PropValue::Choice {
selected: String::new(),
options: text_style_names.to_vec(),
},
},
edit(t!("Text height").as_ref(), "tol_text_height", self.text_height),
],
},
@ -558,20 +651,15 @@ impl PropertyEditable for Tolerance {
},
PropSection {
title: t!("Misc").into_owned(),
props: vec![
ro(
t!("Dimension style").as_ref(),
"tol_dim_style",
if self.dimension_style_name.is_empty() {
"(default)".to_string()
} else {
self.dimension_style_name.clone()
},
),
edit(t!("Direction X").as_ref(), "tol_dir_x", self.direction.x),
edit(t!("Direction Y").as_ref(), "tol_dir_y", self.direction.y),
edit(t!("Direction Z").as_ref(), "tol_dir_z", self.direction.z),
],
props: vec![ro(
t!("Dim style").as_ref(),
"tol_dim_style",
if self.dimension_style_name.is_empty() {
"(default)".to_string()
} else {
self.dimension_style_name.clone()
},
)],
},
]
}
@ -585,9 +673,6 @@ impl PropertyEditable for Tolerance {
"tol_iy" => self.insertion_point.y = v,
"tol_iz" => self.insertion_point.z = v,
"tol_text_height" if v > 0.0 => self.text_height = v,
"tol_dir_x" => self.direction.x = v,
"tol_dir_y" => self.direction.y = v,
"tol_dir_z" => self.direction.z = v,
_ => {}
}
}
@ -923,9 +1008,26 @@ mod tests {
fn compartments_are_measured_by_their_ink_not_the_trailing_pen_gap() {
// Cap height, so glyph units and world units line up.
let h = 9.0_f32;
let document = acadrust::CadDocument::new();
let text_style = crate::entities::text_support::resolve_text_style("Standard", &document);
for src in ["{\\Fgdt;r}", "{\\Fgdt;n}tol{\\Fgdt;m}", "1{\\Fgdt;m}", "A"] {
let cell = parse_cell(src);
let pen: f32 = cell.iter().map(|r| run_advance(&r.text, r.font, h)).sum();
let pen: f32 = cell
.iter()
.map(|run| {
if run.font == SYMBOL_FONT {
run_advance(&run.text, SYMBOL_FONT, h, 1.0, 0.0)
} else {
run_advance(
&run.text,
&text_style.font_name,
h,
text_style.width_factor,
text_style.oblique_angle,
)
}
})
.sum();
let ink: f32 = cell
.iter()
.map(|r| {
@ -934,11 +1036,17 @@ mod tests {
.unwrap_or(0.0)
})
.sum();
let got = content_width(&cell, h);
let got = content_width(&cell, h, &text_style);
// Exactly one trailing gap comes off — the gaps BETWEEN runs are
// real spacing and must stay.
let spacing = letter_spacing(cell.last().unwrap().font, h);
let last = cell.last().unwrap();
let (font, width) = if last.font == SYMBOL_FONT {
(SYMBOL_FONT, 1.0)
} else {
(text_style.font_name.as_str(), text_style.width_factor)
};
let spacing = letter_spacing(font, h, width);
assert!(
(got - (pen - spacing)).abs() < 1e-3,
"{src:?}: content {got} should be pen {pen} less one {spacing} gap"

View file

@ -0,0 +1,623 @@
use acadrust::entities::{Dimension, DimensionArc};
use acadrust::types::{Handle, Vector3};
use acadrust::EntityType;
use cadkernel::geom2d::tessellate::{arc, DEFAULT_SEGMENTS_PER_RADIAN};
use glam::DVec3;
use crate::command::{
CadCommand, CmdOption, CmdResult, DimensionAssociationInput,
DimensionAssociationSource, WorkingPlane,
};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::dimension_assoc::{
polyline_arc_point_marker, RadialSourceGeometry,
ARC_DIMENSION_POINT_MARKER, POLYLINE_ARC_CENTER_MARKER,
};
use crate::scene::model::wire_model::WireModel;
pub const ICON: IconKind =
IconKind::Svg(include_bytes!("../../../assets/icons/dim_angular.svg"));
pub fn tool() -> ToolDef {
ToolDef {
id: "DIMARC",
label: "Arc Length",
icon: ICON,
event: ModuleEvent::Command("DIMARC".to_string()),
}
}
#[derive(Clone, Copy)]
enum SourceBinding {
Arc,
PolylineSegment(i32),
}
#[derive(Clone, Copy)]
struct ArcSelection {
source: RadialSourceGeometry,
binding: SourceBinding,
start_angle: f64,
end_angle: f64,
is_partial: bool,
}
impl ArcSelection {
fn new(source: RadialSourceGeometry, binding: SourceBinding) -> Option<Self> {
let sweep = positive_sweep(source.start_angle, source.end_angle);
(source.limited && source.radius > 1.0e-12 && sweep > 1.0e-12).then_some(Self {
source,
binding,
start_angle: source.start_angle,
end_angle: source.start_angle + sweep,
is_partial: false,
})
}
fn sweep(self) -> f64 {
positive_sweep(self.start_angle, self.end_angle)
}
fn point_at(self, angle: f64) -> DVec3 {
dvec(self.source.point_at_angle(angle))
}
fn center(self) -> DVec3 {
dvec(self.source.center_world())
}
fn project(self, point: DVec3) -> Option<DVec3> {
let point = self.source.plane.project(point.to_array())?;
Some(DVec3::from_array(self.source.plane.point_at(point)))
}
fn plane(self) -> WorkingPlane {
WorkingPlane::new(
DVec3::from_array(self.source.plane.origin),
DVec3::from_array(self.source.plane.x_axis),
DVec3::from_array(self.source.plane.y_axis),
)
}
fn clamped_angle(self, point: DVec3) -> f64 {
let raw = self.source.angle_at(point.to_array());
let relative = (raw - self.start_angle).rem_euclid(std::f64::consts::TAU);
let sweep = self.sweep();
if relative <= sweep + 1.0e-10 {
return self.start_angle + relative.min(sweep);
}
let start_distance = relative.min(std::f64::consts::TAU - relative);
let end_relative = (raw - self.end_angle).rem_euclid(std::f64::consts::TAU);
let end_distance = end_relative.min(std::f64::consts::TAU - end_relative);
if start_distance <= end_distance {
self.start_angle
} else {
self.end_angle
}
}
fn with_partial(self, first: f64, second: f64) -> Option<Self> {
let first = (first - self.start_angle)
.rem_euclid(std::f64::consts::TAU)
.clamp(0.0, self.sweep());
let second = (second - self.start_angle)
.rem_euclid(std::f64::consts::TAU)
.clamp(0.0, self.sweep());
let (start, end) = if first <= second {
(first, second)
} else {
(second, first)
};
(end - start > 1.0e-12).then_some(Self {
start_angle: self.start_angle + start,
end_angle: self.start_angle + end,
is_partial: true,
..self
})
}
fn association_sources(self, handle: Handle) -> Vec<Option<DimensionAssociationSource>> {
let source_sweep = positive_sweep(self.source.start_angle, self.source.end_angle);
let parameter = |angle: f64| {
((angle - self.source.start_angle) / source_sweep).clamp(0.0, 1.0)
};
match self.binding {
SourceBinding::Arc => vec![
Some(DimensionAssociationSource::explicit(handle, -3, 0.0)),
Some(DimensionAssociationSource::explicit(
handle,
ARC_DIMENSION_POINT_MARKER,
parameter(self.start_angle),
)),
Some(DimensionAssociationSource::explicit(
handle,
ARC_DIMENSION_POINT_MARKER,
parameter(self.end_angle),
)),
],
SourceBinding::PolylineSegment(segment) => {
let point_marker = polyline_arc_point_marker(segment);
vec![
Some(DimensionAssociationSource::explicit(
handle,
POLYLINE_ARC_CENTER_MARKER,
segment as f64,
)),
Some(DimensionAssociationSource::explicit(
handle,
point_marker,
parameter(self.start_angle),
)),
Some(DimensionAssociationSource::explicit(
handle,
point_marker,
parameter(self.end_angle),
)),
]
}
}
}
}
#[derive(Clone, Copy)]
enum Step {
SelectObject,
DimLine(ArcSelection),
PartialFirst(ArcSelection),
PartialSecond {
selection: ArcSelection,
first_angle: f64,
},
}
pub struct ArcLengthDimensionCommand {
step: Step,
picked_entity: Option<EntityType>,
source_handle: Option<Handle>,
text_override: Option<String>,
awaiting_text: bool,
text_angle: Option<f64>,
awaiting_angle: bool,
leader_enabled: bool,
}
impl ArcLengthDimensionCommand {
pub fn new() -> Self {
Self {
step: Step::SelectObject,
picked_entity: None,
source_handle: None,
text_override: None,
awaiting_text: false,
text_angle: None,
awaiting_angle: false,
leader_enabled: false,
}
}
fn editor_anchor(&self) -> DVec3 {
match self.step {
Step::SelectObject => DVec3::ZERO,
Step::DimLine(selection)
| Step::PartialFirst(selection)
| Step::PartialSecond { selection, .. } => {
selection.point_at(selection.start_angle + selection.sweep() * 0.5)
}
}
}
fn commit_dimension(&self, selection: ArcSelection, point: DVec3) -> CmdResult {
let plane = selection.plane();
let center = plane.to_local(selection.center());
let first = plane.to_local(selection.point_at(selection.start_angle));
let second = plane.to_local(selection.point_at(selection.end_angle));
let Some(point) = selection.project(point) else {
return CmdResult::NeedPoint;
};
let picked = plane.to_local(point);
let picked_radius = (picked - center).truncate().length();
if !picked_radius.is_finite() || picked_radius <= 1.0e-12 {
return CmdResult::NeedPoint;
}
let mut dimension = DimensionArc::default();
dimension.center_point = v3(center);
dimension.first_extension_point = v3(first);
dimension.second_extension_point = v3(second);
dimension.definition_point = v3(picked);
dimension.is_partial = selection.is_partial;
dimension.arc_start_parameter = selection.start_angle;
dimension.arc_end_parameter = selection.end_angle;
dimension.base.definition_point = dimension.definition_point;
dimension.base.text_middle_point = dimension.definition_point;
dimension.base.insertion_point = dimension.definition_point;
dimension.has_leader = self.leader_enabled && selection.sweep() > std::f64::consts::FRAC_PI_2;
if dimension.has_leader {
let middle = selection.start_angle + selection.sweep() * 0.5;
let anchor = DVec3::new(
center.x + picked_radius * middle.cos(),
center.y + picked_radius * middle.sin(),
picked.z,
);
dimension.first_leader_point = v3(anchor);
dimension.second_leader_point = v3(picked);
dimension.definition_point = v3(anchor);
dimension.base.definition_point = dimension.definition_point;
dimension.base.text_user_positioned = true;
}
dimension.base.actual_measurement = dimension.measurement();
crate::entities::dimension::set_dimension_text_override(
&mut dimension.base,
self.text_override.clone(),
);
if let Some(angle) = self.text_angle {
dimension.base.text_rotation = angle;
}
let association = self.source_handle.map_or_else(
|| DimensionAssociationInput::Explicit(Vec::new()),
|handle| {
DimensionAssociationInput::Explicit(
selection.association_sources(handle),
)
},
);
CmdResult::CommitDimension {
entity: plane.place_entity(EntityType::Dimension(Dimension::Arc(dimension))),
association,
}
}
}
impl CadCommand for ArcLengthDimensionCommand {
fn set_working_plane(&mut self, _plane: WorkingPlane) {}
fn name(&self) -> &'static str {
"DIMARC"
}
fn prompt(&self) -> String {
if self.awaiting_text {
return "DIMARC Enter dimension text (blank = measured value):".to_string();
}
if self.awaiting_angle {
return "DIMARC Specify text angle (degrees):".to_string();
}
match self.step {
Step::SelectObject => {
"DIMARC Select arc or polyline arc segment:".to_string()
}
Step::DimLine(selection) => {
let leader_option = if selection.sweep() > std::f64::consts::FRAC_PI_2 {
if self.leader_enabled { "/No Leader" } else { "/Leader" }
} else {
""
};
format!(
"DIMARC Specify arc length dimension location [Mtext/Text/Angle/Partial{leader_option}]:"
)
}
Step::PartialFirst(_) => {
"DIMARC Specify first point of partial arc:".to_string()
}
Step::PartialSecond { .. } => {
"DIMARC Specify second point of partial arc:".to_string()
}
}
}
fn on_point(&mut self, point: DVec3) -> CmdResult {
match self.step {
Step::SelectObject => CmdResult::NeedPoint,
Step::DimLine(selection) => self.commit_dimension(selection, point),
Step::PartialFirst(selection) => {
self.step = Step::PartialSecond {
selection,
first_angle: selection.clamped_angle(point),
};
CmdResult::NeedPoint
}
Step::PartialSecond {
selection,
first_angle,
} => {
let second_angle = selection.clamped_angle(point);
let Some(partial) = selection.with_partial(first_angle, second_angle) else {
return CmdResult::NeedPoint;
};
self.leader_enabled = false;
self.step = Step::DimLine(partial);
CmdResult::NeedPoint
}
}
}
fn on_enter(&mut self) -> CmdResult {
if self.awaiting_text {
self.awaiting_text = false;
return CmdResult::NeedPoint;
}
if self.awaiting_angle {
self.awaiting_angle = false;
return CmdResult::NeedPoint;
}
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn wants_text_input(&self) -> bool {
true
}
fn point_step_accepts_keywords(&self) -> bool {
!self.awaiting_text && !self.awaiting_angle
}
fn wants_text_with_spaces(&self) -> bool {
self.awaiting_text
}
fn options(&self) -> Vec<CmdOption> {
let Step::DimLine(selection) = self.step else {
return Vec::new();
};
if self.awaiting_text || self.awaiting_angle {
return Vec::new();
}
let mut options = vec![
CmdOption::new("MText", "MTEXT"),
CmdOption::new("Text", "TEXT"),
CmdOption::new("Angle", "ANGLE"),
CmdOption::new("Partial", "PARTIAL"),
];
if selection.sweep() > std::f64::consts::FRAC_PI_2 {
options.push(if self.leader_enabled {
CmdOption::new("No Leader", "NOLEADER")
} else {
CmdOption::new("Leader", "LEADER")
});
}
options
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.awaiting_text {
let value = text.trim();
self.text_override = if value.is_empty() || value == "<>" {
None
} else {
Some(value.to_string())
};
self.awaiting_text = false;
return Some(CmdResult::NeedPoint);
}
if self.awaiting_angle {
let value = text.trim();
self.text_angle = if value.is_empty() {
None
} else {
crate::entities::common::parse_typed_angle(value)
};
self.awaiting_angle = false;
return Some(CmdResult::NeedPoint);
}
let Step::DimLine(selection) = self.step else {
return None;
};
match text.trim().to_ascii_uppercase().as_str() {
"M" | "MTEXT" => Some(CmdResult::SuspendForMTextInput {
pos: self.editor_anchor(),
initial: self.text_override.clone().unwrap_or_default(),
height: 2.5,
}),
"T" | "TEXT" => {
self.awaiting_text = true;
Some(CmdResult::NeedPoint)
}
"A" | "ANGLE" => {
self.awaiting_angle = true;
Some(CmdResult::NeedPoint)
}
"P" | "PARTIAL" => {
self.step = Step::PartialFirst(selection);
Some(CmdResult::NeedPoint)
}
"L" | "LEADER" if selection.sweep() > std::f64::consts::FRAC_PI_2 => {
self.leader_enabled = true;
Some(CmdResult::NeedPoint)
}
"N" | "NOLEADER" => {
self.leader_enabled = false;
Some(CmdResult::NeedPoint)
}
_ => None,
}
}
fn on_editor_text(&mut self, value: String) {
let value = value.trim();
self.text_override = if value.is_empty() || value == "<>" {
None
} else {
Some(value.to_string())
};
}
fn on_editor_closed(&mut self, _committed: bool) -> CmdResult {
CmdResult::NeedPoint
}
fn needs_entity_pick(&self) -> bool {
matches!(self.step, Step::SelectObject)
}
fn entity_pick_highlights_hover(&self) -> bool {
true
}
fn inject_before_entity_pick(&self) -> bool {
true
}
fn inject_picked_entity(&mut self, entity: EntityType) {
self.picked_entity = Some(entity);
}
fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult {
let Some(entity) = self.picked_entity.take() else {
return CmdResult::NeedPoint;
};
let Some(source) = crate::scene::dimension_assoc::radial_source_at(
&entity,
Vector3::new(point.x, point.y, point.z),
) else {
return CmdResult::NeedPoint;
};
if !source.limited {
return CmdResult::NeedPoint;
}
let binding = match entity {
EntityType::Arc(_) => SourceBinding::Arc,
EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => {
SourceBinding::PolylineSegment(source.marker)
}
_ => return CmdResult::NeedPoint,
};
let Some(selection) = ArcSelection::new(source, binding) else {
return CmdResult::NeedPoint;
};
self.source_handle = Some(handle);
self.step = Step::DimLine(selection);
CmdResult::NeedPoint
}
fn on_mouse_move(&mut self, point: DVec3) -> Option<WireModel> {
match self.step {
Step::SelectObject | Step::PartialFirst(_) => None,
Step::DimLine(selection) => Some(dimension_preview(
selection,
point,
self.leader_enabled,
)),
Step::PartialSecond {
selection,
first_angle,
} => {
let second_angle = selection.clamped_angle(point);
let partial = selection.with_partial(first_angle, second_angle)?;
Some(partial_preview(partial))
}
}
}
}
fn positive_sweep(start: f64, end: f64) -> f64 {
let raw = end - start;
let mut sweep = raw.rem_euclid(std::f64::consts::TAU);
if sweep <= 1.0e-12 && raw.abs() > 1.0e-12 {
sweep = std::f64::consts::TAU;
}
sweep
}
fn dimension_preview(
selection: ArcSelection,
point: DVec3,
leader: bool,
) -> WireModel {
let plane = selection.plane();
let center = plane.to_local(selection.center());
let first = plane.to_local(selection.point_at(selection.start_angle));
let second = plane.to_local(selection.point_at(selection.end_angle));
let Some(point) = selection.project(point) else {
return preview_wire(Vec::new(), "dimarc_preview");
};
let point = plane.to_local(point);
let radius = (point - center).truncate().length();
if radius <= 1.0e-12 {
return preview_wire(Vec::new(), "dimarc_preview");
}
let start_land = DVec3::new(
center.x + radius * selection.start_angle.cos(),
center.y + radius * selection.start_angle.sin(),
point.z,
);
let end_land = DVec3::new(
center.x + radius * selection.end_angle.cos(),
center.y + radius * selection.end_angle.sin(),
point.z,
);
let mut points = vec![first, start_land, nan(), second, end_land, nan()];
points.extend(
arc(
[center.x, center.y],
radius,
selection.start_angle,
selection.end_angle,
point.z,
DEFAULT_SEGMENTS_PER_RADIAN,
)
.into_iter()
.map(DVec3::from_array),
);
if leader && selection.sweep() > std::f64::consts::FRAC_PI_2 {
let middle = selection.start_angle + selection.sweep() * 0.5;
points.extend([
nan(),
DVec3::new(
center.x + radius * middle.cos(),
center.y + radius * middle.sin(),
point.z,
),
point,
]);
}
preview_wire(
points
.into_iter()
.map(|value| if value.is_nan() { value } else { plane.to_world(value) })
.collect(),
"dimarc_preview",
)
}
fn partial_preview(selection: ArcSelection) -> WireModel {
let points = arc(
selection.source.center,
selection.source.radius,
selection.start_angle,
selection.end_angle,
0.0,
DEFAULT_SEGMENTS_PER_RADIAN,
)
.into_iter()
.map(|point| DVec3::from_array(selection.source.plane.point_at([point[0], point[1]])))
.collect();
preview_wire(points, "dimarc_partial_preview")
}
fn v3(point: DVec3) -> Vector3 {
Vector3::new(point.x, point.y, point.z)
}
fn dvec(point: Vector3) -> DVec3 {
DVec3::new(point.x, point.y, point.z)
}
fn nan() -> DVec3 {
DVec3::splat(f64::NAN)
}
fn preview_wire(points: Vec<DVec3>, name: &str) -> WireModel {
WireModel::solid_f64(
name.to_string(),
points.into_iter().map(|point| point.to_array()).collect(),
WireModel::CYAN,
false,
)
}
inventory::submit!(crate::command::CommandRegistration { names: &["DIMARC"] });

View file

@ -54,9 +54,12 @@ pub struct JoggedRadiusDimensionCommand {
impl JoggedRadiusDimensionCommand {
pub fn new(defaults: DimensionCreationDefaults, annotation_multiplier: f64) -> Self {
let editor_height = defaults.text_height
* defaults.scale
* annotation_multiplier.max(f64::EPSILON);
let display_scale = if defaults.annotative {
annotation_multiplier.max(f64::EPSILON)
} else {
defaults.scale
};
let editor_height = defaults.text_height * display_scale;
Self {
step: Step::SelectObject,
defaults,

View file

@ -1,6 +1,7 @@
// Annotate module — dimension, text, leader, table, and markup tools.
pub mod aligned_dim;
pub mod arc_length_dim;
pub mod angular_dim;
pub mod data_extract;
pub mod data_link;
@ -99,6 +100,11 @@ impl CadModule for AnnotateModule {
angular_dim::tool().label,
angular_dim::tool().icon,
),
(
arc_length_dim::tool().id,
arc_length_dim::tool().label,
arc_length_dim::tool().icon,
),
(
radius_dim::tool().id,
radius_dim::tool().label,

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 {
CmdResult::Cancel
match self.step {
Step::Start => CmdResult::Cancel,
Step::Justification | Step::Style => {
self.step = Step::Start;
CmdResult::NeedPoint
}
Step::Height => {
if self.is_two_point() {
self.open_editor()
} else {
self.step = Step::Rotation;
CmdResult::NeedPoint
}
}
Step::Rotation => self.open_editor(),
Step::SecondPoint => CmdResult::NeedPoint,
}
}
fn on_editor_closed(&mut self, committed: bool) -> CmdResult {
if committed {
self.open_next_line()
} else {
CmdResult::Cancel
}
}
fn on_editor_display_height(&mut self, height: f64) {
if height.is_finite() && height > 1.0e-9 {
self.last_display_height = Some(height);
}
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_mouse_move(&mut self, _pt: DVec3) -> Option<WireModel> {
fn on_mouse_move(&mut self, _point: DVec3) -> Option<WireModel> {
None
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] }); // TextCommand
inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] });

View file

@ -1,8 +1,6 @@
// TOLERANCE command — place a GD&T (geometric dimensioning & tolerancing) frame.
//
// Workflow:
// 1. Text: Enter tolerance string (e.g. "%%v0.05|A" or plain text)
// 2. Point: Click insertion point
// The structured editor prepares the frame; this command places it.
use acadrust::entities::Tolerance;
use acadrust::types::Vector3;
@ -25,20 +23,17 @@ pub fn tool() -> ToolDef {
}
}
enum Step {
Text,
Insertion { text: String },
}
pub struct ToleranceCommand {
step: Step,
text: String,
preview_strokes: Vec<Vec<[f32; 2]>>,
plane: WorkingPlane,
}
impl ToleranceCommand {
pub fn new() -> Self {
pub fn with_text(text: String, preview_strokes: Vec<Vec<[f32; 2]>>) -> Self {
Self {
step: Step::Text,
text,
preview_strokes,
plane: WorkingPlane::default(),
}
}
@ -54,36 +49,18 @@ impl CadCommand for ToleranceCommand {
}
fn prompt(&self) -> String {
match &self.step {
Step::Text => t!("TOLERANCE Enter tolerance text:").into_owned(),
Step::Insertion { text } => {
t!("TOLERANCE Specify insertion point [%{text}]:", text = text).into_owned()
}
}
t!("TOLERANCE Specify insertion point:").into_owned()
}
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::Text)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim().to_string();
if t.is_empty() {
return Some(CmdResult::Cancel);
}
self.step = Step::Insertion { text: t };
Some(CmdResult::NeedPoint)
false
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
if let Step::Insertion { text } = &self.step {
let point = self.plane.to_local(pt);
let ins = Vector3::new(point.x, point.y, point.z);
let tol = Tolerance::with_text(ins, text.clone());
CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Tolerance(tol)))
} else {
CmdResult::NeedPoint
}
let point = self.plane.to_local(pt);
let ins = Vector3::new(point.x, point.y, point.z);
let tol = Tolerance::with_text(ins, self.text.clone());
CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Tolerance(tol)))
}
fn on_enter(&mut self) -> CmdResult {
@ -91,17 +68,18 @@ impl CadCommand for ToleranceCommand {
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
if !matches!(self.step, Step::Insertion { .. }) {
return None;
let mut points = Vec::new();
for stroke in &self.preview_strokes {
if stroke.len() < 2 {
continue;
}
if !points.is_empty() {
points.push(DVec3::splat(f64::NAN));
}
points.extend(stroke.iter().map(|[x, y]| {
pt + self.plane.x * *x as f64 + self.plane.y * *y as f64
}));
}
let d = 0.15;
let points = [
pt - self.plane.x * d,
pt + self.plane.x * d,
DVec3::splat(f64::NAN),
pt - self.plane.y * d,
pt + self.plane.y * d,
];
Some(WireModel {
point_marker: None,
taper_widths: Vec::new(),
@ -119,10 +97,7 @@ impl CadCommand for ToleranceCommand {
dash_align_end: None,
text_verts: Vec::new(),
name: "tolerance_preview".into(),
points: points
.iter()
.map(|point| point.as_vec3().to_array())
.collect(),
points: points.iter().map(|point| point.as_vec3().to_array()).collect(),
points_low: Vec::new(),
color: WireModel::CYAN,
selected: false,

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

@ -26,7 +26,7 @@ use acadrust::tables::BlockRecord;
use acadrust::types::Vector3;
use acadrust::{CadDocument, EntityType, Handle};
use crate::command::{CadCommand, CmdResult};
use crate::command::{CadCommand, CmdResult, WorkingPlane};
use crate::entities::curve::lwpolyline_world_xy;
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use glam::DVec3;
@ -909,6 +909,42 @@ fn norm2(dx: f64, dy: f64, fx: f64, fy: f64) -> (f64, f64) {
}
fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec<EntityType> {
if let Dimension::Arc(arc) = dim {
if let Some((local_arc, normal)) =
crate::entities::dimension::arc_dimension_in_ocs(arc)
{
let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes((
normal.x, normal.y, normal.z,
));
let plane = WorkingPlane::new(
DVec3::ZERO,
DVec3::new(x_axis.0, x_axis.1, x_axis.2),
DVec3::new(y_axis.0, y_axis.1, y_axis.2),
);
return explode_dimension(&Dimension::Arc(local_arc), doc)
.into_iter()
.map(|entity| plane.place_entity(entity))
.collect();
}
}
if let Dimension::LargeRadial(radial) = dim {
if let Some((local_radial, normal)) =
crate::entities::dimension::large_radial_dimension_in_ocs(radial)
{
let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes((
normal.x, normal.y, normal.z,
));
let plane = WorkingPlane::new(
DVec3::ZERO,
DVec3::new(x_axis.0, x_axis.1, x_axis.2),
DVec3::new(y_axis.0, y_axis.1, y_axis.2),
);
return explode_dimension(&Dimension::LargeRadial(local_radial), doc)
.into_iter()
.map(|entity| plane.place_entity(entity))
.collect();
}
}
let base = dim.base();
let met = dim_metrics(dim, doc);
@ -1190,6 +1226,8 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec<EntityType> {
}
}
Dimension::Arc(d) => {
let explicit_sweep = crate::entities::dimension::arc_dimension_angles(d)
.map(|(start, end)| (start as f64, end as f64));
result.extend(angular_block_segs(
d.center_point,
d.first_extension_point,
@ -1198,10 +1236,7 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec<EntityType> {
&met,
&ext_c,
&dim_c,
d.is_partial.then_some((
d.arc_start_parameter,
d.arc_end_parameter,
)),
explicit_sweep,
));
if d.has_leader {
result.push(make_seg(
@ -1246,6 +1281,15 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec<EntityType> {
}
}
let symbol_points =
crate::entities::dimension::baked_arc_length_symbol_points(dim, doc, 1.0);
if symbol_points.len() > 1 {
let text_c = dim_common(&base.common, met.dimclrt, -2);
for pair in symbol_points.windows(2) {
result.push(make_seg(&pair[0], &pair[1], &text_c));
}
}
// Measurement text is the live render's own Text/MText entity (value,
// position, height, rotation, alignment, text style, MText handling all
// shared), so the baked block matches the on-screen dimension and the label

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

@ -36,11 +36,6 @@ pub fn general_section(entity: &EntityType) -> PropSection {
let mut section = PropSection {
title: t!("General").into_owned(),
props: vec![
Property {
label: t!("Handle").into_owned(),
field: "handle",
value: PropValue::ReadOnly(common.handle.value().to_string()),
},
Property {
label: t!("Color").into_owned(),
field: "color",
@ -94,12 +89,7 @@ pub fn general_section(entity: &EntityType) -> PropSection {
],
};
if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) {
section.props.retain(|prop| prop.field != "handle");
}
if matches!(entity, EntityType::Point(_)) {
section.props.retain(|prop| prop.field != "handle");
let hyperlink = section.props.iter().position(|prop| prop.field == "hyperlink");
let transparency = section.props.iter().position(|prop| prop.field == "transparency");
if let (Some(hyperlink), Some(transparency)) = (hyperlink, transparency) {

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

@ -416,6 +416,7 @@ fn tessellate_entity_inner(
| EntityType::MText(_)
| EntityType::Dimension(_)
| EntityType::MultiLeader(_)
| EntityType::Tolerance(_)
) {
crate::scene::annotative::effective_annotation_scale_for(
document,

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,14 +921,35 @@ pub fn tessellate(
run.bold,
&run.text,
);
crate::scene::pipeline::text_gpu::push_glyph_vertices(
&mut sdf_verts,
&quads,
[slx_v, sly_v, elev_v],
anno,
gcolor,
0.0,
);
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,
[slx_v, sly_v, elev_v],
anno,
gcolor,
0.0,
);
}
}
}
}
@ -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,13 +1012,18 @@ pub fn tessellate(
} else {
color_or_inherit(&m.background_color, bg_color)
};
let mut ft = Vec::with_capacity(6);
let mut ftl = Vec::with_capacity(6);
for &k in &[0usize, 1, 2, 0, 2, 3] {
let (h, lo) =
split_ds_xyz(corners[k][0], corners[k][1], elev_v);
ft.push(h);
ftl.push(lo);
let mut ft = Vec::with_capacity(6 * corner_groups.len());
let mut ftl = Vec::with_capacity(6 * corner_groups.len());
for corners in &corner_groups {
for &k in &[0usize, 1, 2, 0, 2, 3] {
let (h, lo) = split_ds_xyz(
corners[k][0],
corners[k][1],
elev_v,
);
ft.push(h);
ftl.push(lo);
}
}
wires.push(WireModel {
point_marker: None,
@ -907,19 +1062,24 @@ pub fn tessellate(
// Text frame — a closed rectangle in the text
// colour around the same box.
if has_frame {
let loop_xy = [
corners[0],
corners[1],
corners[2],
corners[3],
corners[0],
];
let mut fp = Vec::with_capacity(5);
let mut fpl = Vec::with_capacity(5);
for &[x, y] in &loop_xy {
let (h, lo) = split_ds_xyz(x, y, elev_v);
fp.push(h);
fpl.push(lo);
let mut fp = Vec::with_capacity(6 * corner_groups.len());
let mut fpl = Vec::with_capacity(6 * corner_groups.len());
for (group_index, corners) in corner_groups.iter().enumerate() {
if group_index > 0 {
fp.push([f32::NAN; 3]);
fpl.push([0.0; 3]);
}
for &[x, y] in &[
corners[0],
corners[1],
corners[2],
corners[3],
corners[0],
] {
let (h, lo) = split_ds_xyz(x, y, elev_v);
fp.push(h);
fpl.push(lo);
}
}
wires.push(WireModel {
point_marker: None,

View file

@ -138,10 +138,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

@ -16,6 +16,19 @@ use crate::command::DimensionAssociationSource;
use super::{ChangeKind, Scene};
pub(crate) const POLYLINE_ARC_CENTER_MARKER: i32 = -4;
const POLYLINE_ARC_POINT_MARKER_BASE: i32 = -5;
pub(crate) const ARC_DIMENSION_POINT_MARKER: i32 = -1;
pub(crate) fn polyline_arc_point_marker(segment: i32) -> i32 {
POLYLINE_ARC_POINT_MARKER_BASE - segment.max(0)
}
fn polyline_arc_segment_from_point_marker(marker: i32) -> Option<i32> {
(marker <= POLYLINE_ARC_POINT_MARKER_BASE)
.then_some(POLYLINE_ARC_POINT_MARKER_BASE - marker)
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct RadialSourceGeometry {
pub plane: Plane,
@ -329,7 +342,21 @@ fn source_marker(entity: &EntityType, point: Vector3) -> Option<i32> {
fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Option<Vector3> {
let source = *reference.xrefs.first()?;
let entity = scene.document.get_entity(source)?;
if reference.main_gs_marker == -4 {
if reference.main_gs_marker == ARC_DIMENSION_POINT_MARKER {
let radial = radial_source_for_marker(entity, 0)?;
let sweep = positive_sweep(radial.start_angle, radial.end_angle);
let angle = radial.start_angle + sweep * reference.osnap_distance.clamp(0.0, 1.0);
return Some(radial.point_at_angle(angle));
}
if let Some(segment) =
polyline_arc_segment_from_point_marker(reference.main_gs_marker)
{
let radial = radial_source_for_marker(entity, segment)?;
let sweep = positive_sweep(radial.start_angle, radial.end_angle);
let angle = radial.start_angle + sweep * reference.osnap_distance.clamp(0.0, 1.0);
return Some(radial.point_at_angle(angle));
}
if reference.main_gs_marker == POLYLINE_ARC_CENTER_MARKER {
let segment = reference.osnap_distance.round().max(0.0) as usize;
return polyline_arc_center(entity, segment);
}
@ -422,10 +449,82 @@ fn dimension_reference_points(dimension: &Dimension) -> Vec<Vector3> {
angular.definition_point,
],
Dimension::Ordinate(ordinate) => vec![ordinate.feature_location],
Dimension::Arc(arc) => vec![
arc.center_point,
arc.first_extension_point,
arc.second_extension_point,
],
_ => Vec::new(),
}
}
fn positive_sweep(start: f64, end: f64) -> f64 {
let raw = end - start;
let mut sweep = raw.rem_euclid(TAU);
if sweep <= 1.0e-12 && raw.abs() > 1.0e-12 {
sweep = TAU;
}
sweep
}
fn signed_angle_delta(value: f64) -> f64 {
(value + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI
}
fn angle_about_plane(
plane: Plane,
center: Vector3,
point: Vector3,
) -> f64 {
let delta = [
point.x - center.x,
point.y - center.y,
point.z - center.z,
];
let dot = |axis: [f64; 3]| {
delta[0] * axis[0] + delta[1] * axis[1] + delta[2] * axis[2]
};
dot(plane.y_axis).atan2(dot(plane.x_axis))
}
fn point_on_radial_circle(
radial: RadialSourceGeometry,
radius: f64,
angle: f64,
) -> Vector3 {
vector3(radial.plane.point_at([
radial.center[0] + radius * angle.cos(),
radial.center[1] + radius * angle.sin(),
]))
}
fn plane_from_normal(origin: Vector3, normal: Vector3) -> Plane {
let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes((
normal.x, normal.y, normal.z,
));
Plane::from_axes(
[origin.x, origin.y, origin.z],
[x_axis.0, x_axis.1, x_axis.2],
[y_axis.0, y_axis.1, y_axis.2],
)
}
fn remap_plane_offset(
old_plane: Plane,
new_plane: Plane,
old_origin: Vector3,
old_point: Vector3,
new_origin: Vector3,
) -> Vector3 {
let old_origin = old_plane.project(dpoint(old_origin)).unwrap_or([0.0; 2]);
let old_point = old_plane.project(dpoint(old_point)).unwrap_or(old_origin);
let new_origin_uv = new_plane.project(dpoint(new_origin)).unwrap_or([0.0; 2]);
vector3(new_plane.point_at([
new_origin_uv[0] + old_point[0] - old_origin[0],
new_origin_uv[1] + old_point[1] - old_origin[1],
]))
}
pub(crate) fn dimension_is_associative(
document: &acadrust::CadDocument,
dimension: Handle,
@ -807,6 +906,18 @@ impl Scene {
let radial = radial_source_for_marker(entity, reference.main_gs_marker)?;
Some((radial, reference.osnap_distance))
});
let arc_source = association.references[0].first().and_then(|reference| {
let source = *reference.xrefs.first()?;
let entity = self.document.get_entity(source)?;
let segment = match reference.main_gs_marker {
-3 => 0,
POLYLINE_ARC_CENTER_MARKER => {
reference.osnap_distance.round().max(0.0) as i32
}
_ => return None,
};
radial_source_for_marker(entity, segment)
});
if radial_source.is_none() && resolved.iter().all(Option::is_none) {
continue;
}
@ -936,7 +1047,95 @@ impl Scene {
}
ordinate.refresh_measurement();
}
_ => continue,
Dimension::Arc(arc) => {
let Some(radial) = arc_source else {
continue;
};
let center = resolved[0].unwrap_or_else(|| radial.center_world());
let Some(first) = resolved[1] else {
continue;
};
let Some(second) = resolved[2] else {
continue;
};
let old_center = arc.center_point;
let old_definition = arc.definition_point;
let old_plane = plane_from_normal(old_center, arc.base.normal);
let old_source_radius = old_center.distance(&arc.first_extension_point);
let old_dim_radius = old_center.distance(&old_definition);
let radial_offset = old_dim_radius - old_source_radius;
let old_mid = arc.arc_start_parameter
+ positive_sweep(
arc.arc_start_parameter,
arc.arc_end_parameter,
) * 0.5;
let old_definition_angle =
angle_about_plane(old_plane, old_center, old_definition);
let definition_angle_offset =
signed_angle_delta(old_definition_angle - old_mid);
let start = radial.angle_at(dpoint(first));
let end_at = radial.angle_at(dpoint(second));
let sweep = positive_sweep(start, end_at);
let end = start + sweep;
let new_radius = center.distance(&first);
if !new_radius.is_finite() || new_radius <= 1.0e-12 {
continue;
}
let dim_radius = (new_radius + radial_offset).max(1.0e-9);
let middle = start + sweep * 0.5;
let definition = point_on_radial_circle(
radial,
dim_radius,
middle + definition_angle_offset,
);
let text_middle = remap_plane_offset(
old_plane,
radial.plane,
old_definition,
arc.base.text_middle_point,
definition,
);
let insertion = remap_plane_offset(
old_plane,
radial.plane,
old_definition,
arc.base.insertion_point,
definition,
);
let second_leader = remap_plane_offset(
old_plane,
radial.plane,
old_definition,
arc.second_leader_point,
definition,
);
arc.center_point = center;
arc.first_extension_point = first;
arc.second_extension_point = second;
arc.arc_start_parameter = start;
arc.arc_end_parameter = end;
arc.definition_point = definition;
arc.base.definition_point = definition;
if let Some(normal) = radial.plane.normal() {
arc.base.normal = vector3(normal);
}
if arc.base.text_user_positioned {
arc.base.text_middle_point = text_middle;
arc.base.insertion_point = insertion;
} else {
arc.base.text_middle_point = definition;
arc.base.insertion_point = definition;
}
if arc.has_leader {
arc.first_leader_point =
point_on_radial_circle(radial, dim_radius, middle);
arc.second_leader_point = second_leader;
}
arc.base.actual_measurement = arc.measurement();
}
}
dimension.base_mut().actual_measurement = dimension.measurement();
refreshed.push((association.dimension, ChangeKind::Modified));

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

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

@ -0,0 +1,567 @@
//! Structured feature-control-frame editor.
//!
//! The entity stores a compact escape string, but users work with named
//! symbols, tolerance compartments and datum references. This dialog keeps
//! that storage detail behind typed controls and can also reopen existing
//! frames without losing their compartment layout.
use std::fmt;
use acadrust::Handle;
use iced::widget::{button, checkbox, column, container, row, text, text_input, Space};
use iced::{Border, Element, Fill, Length, Theme};
use crate::app::Message;
use crate::t;
const EMPTY: &str = "";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ToleranceEntry {
pub diameter: bool,
pub value: String,
pub material: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DatumEntry {
pub value: String,
pub material: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct State {
pub editing: Option<Handle>,
pub symbol: String,
pub tolerances: [ToleranceEntry; 2],
pub datums: [DatumEntry; 3],
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 {
fn default() -> Self {
Self {
editing: None,
symbol: String::new(),
tolerances: std::array::from_fn(|_| ToleranceEntry::default()),
datums: std::array::from_fn(|_| DatumEntry::default()),
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,
}
}
}
#[derive(Clone, Debug)]
pub enum Field {
Symbol(String),
ToleranceValue(usize, String),
ToleranceMaterial(usize, String),
DatumValue(usize, String),
DatumMaterial(usize, String),
ProjectedHeight(String),
DatumIdentifier(String),
}
#[derive(Clone, Debug)]
pub enum Toggle {
Diameter(usize, bool),
ProjectedZone(bool),
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Choice {
code: &'static str,
label: &'static str,
}
impl fmt::Display for Choice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(crate::i18n::translate(self.label).as_ref())
}
}
const SYMBOLS: [(&str, &str); 15] = [
("", "None"),
("u", "Straightness"),
("c", "Flatness"),
("e", "Circularity"),
("g", "Cylindricity"),
("d", "Profile of a surface"),
("k", "Profile of a line"),
("j", "Position"),
("r", "Concentricity"),
("i", "Symmetry"),
("f", "Parallelism"),
("b", "Perpendicularity"),
("a", "Angularity"),
("h", "Circular runout"),
("t", "Total runout"),
];
const MATERIALS: [(&str, &str); 4] = [
("", "None"),
("m", "Maximum material condition"),
("l", "Least material condition"),
("s", "Regardless of feature size"),
];
fn choices(values: &'static [(&'static str, &'static str)]) -> Vec<Choice> {
values
.iter()
.map(|(code, label)| Choice { code, label })
.collect()
}
fn selected(values: &'static [(&'static str, &'static str)], code: &str) -> Option<Choice> {
values
.iter()
.find(|(value, _)| value.eq_ignore_ascii_case(code))
.map(|(code, label)| Choice { code, label })
}
fn escape(code: &str) -> String {
if code.is_empty() {
String::new()
} else {
format!("{{\\Fgdt;{code}}}")
}
}
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) = remove_escape(input, code) {
return (value, code.to_string());
}
}
(input.to_string(), String::new())
}
impl State {
pub fn from_text(editing: Option<Handle>, raw: &str) -> Self {
let mut state = Self {
editing,
..Self::default()
};
let normalized = raw
.replace("^J", "\n")
.replace("\\P", "\n")
.replace("\r\n", "\n")
.replace('\r', "\n");
let lines: Vec<&str> = normalized.split('\n').collect();
state.original_text = (!raw.is_empty()).then(|| raw.to_string());
if let Some(frame) = lines.first().copied() {
let cells: Vec<&str> = frame.split("%%v").collect();
if let Some(cell) = cells.first() {
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) = 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,
material,
};
}
}
for index in 0..3 {
if let Some(cell) = cells.get(index + 3) {
let (value, material) = parse_material(cell);
state.datums[index] = DatumEntry { value, material };
}
}
}
}
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;
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 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) {
let changed = match field {
Field::Symbol(value) => {
let changed = self.symbol != value;
self.symbol = value;
changed
}
Field::ToleranceValue(index, value) if index < 2 => {
let changed = self.tolerances[index].value != value;
self.tolerances[index].value = value;
changed
}
Field::ToleranceMaterial(index, value) if index < 2 => {
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::DatumMaterial(index, value) if index < 3 => {
let changed = self.datums[index].material != value;
self.datums[index].material = value;
changed
}
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) {
let changed = match toggle {
Toggle::Diameter(index, value) if index < 2 => {
let changed = self.tolerances[index].diameter != value;
self.tolerances[index].diameter = value;
changed
}
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(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);
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.clone();
if !value.trim().is_empty() {
value.push_str(&escape(&entry.material));
}
cells.push(value);
}
while cells.last().is_some_and(String::is_empty) {
cells.pop();
}
cells.join("%%v")
};
let mut rows = vec![frame];
if !self.projected_height.trim().is_empty() || self.projected_zone {
let mut projected = self.projected_height.clone();
if self.projected_zone {
projected.push_str(&escape("p"));
}
rows.push(projected);
}
if !self.datum_identifier.trim().is_empty() {
if rows.len() == 1 {
rows.push(String::new());
}
rows.push(self.datum_identifier.clone());
}
rows.extend(self.extra_rows.iter().cloned());
rows.join("\n")
}
}
fn muted(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.65)),
}
}
fn panel<'a>(title: String, body: Element<'a, Message>) -> Element<'a, Message> {
container(column![text(title).size(11).style(muted), body].spacing(6))
.padding(8)
.width(Fill)
.style(|theme: &Theme| container::Style {
border: Border {
width: 1.0,
radius: 4.0.into(),
color: theme.palette().background.strong.color,
},
..Default::default()
})
.into()
}
fn material_picker<'a>(index: usize, datum: bool, code: &str) -> Element<'a, Message> {
let options = choices(&MATERIALS);
let selected = selected(&MATERIALS, code);
iced::widget::pick_list(selected, options, |choice| choice.to_string())
.on_select(move |choice| {
if datum {
Message::ToleranceDialogField(Field::DatumMaterial(
index,
choice.code.to_string(),
))
} else {
Message::ToleranceDialogField(Field::ToleranceMaterial(
index,
choice.code.to_string(),
))
}
})
.text_size(11)
.padding([3, 6])
.width(Length::Fixed(190.0))
.into()
}
pub fn view_window<'a>(
state: &State,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let symbol_options = choices(&SYMBOLS);
let symbol_selected = selected(&SYMBOLS, &state.symbol);
let symbol = panel(
t!("Geometric characteristic").into_owned(),
iced::widget::pick_list(symbol_selected, symbol_options, |choice| choice.to_string())
.on_select(|choice| {
Message::ToleranceDialogField(Field::Symbol(choice.code.to_string()))
})
.text_size(12)
.padding([4, 6])
.width(Fill)
.into(),
);
let tolerance_rows = state.tolerances.iter().enumerate().fold(
column![].spacing(6),
|column, (index, entry)| {
column.push(
row![
text(format!("{} {}", t!("Tolerance"), index + 1))
.size(11)
.style(muted)
.width(Length::Fixed(82.0)),
checkbox(entry.diameter)
.on_toggle(move |value| Message::ToleranceDialogToggle(
Toggle::Diameter(index, value)
))
.size(14),
text(t!("Diameter")).size(11).width(Length::Fixed(62.0)),
text_input(EMPTY, &entry.value)
.on_input(move |value| Message::ToleranceDialogField(
Field::ToleranceValue(index, value)
))
.size(12)
.padding([3, 6])
.width(Length::Fixed(125.0)),
material_picker(index, false, &entry.material),
]
.spacing(6)
.align_y(iced::Center),
)
},
);
let tolerances = panel(t!("Tolerance values").into_owned(), tolerance_rows.into());
let datum_rows = state.datums.iter().enumerate().fold(
column![].spacing(6),
|column, (index, entry)| {
column.push(
row![
text(format!("{} {}", t!("Datum"), index + 1))
.size(11)
.style(muted)
.width(Length::Fixed(82.0)),
text_input(EMPTY, &entry.value)
.on_input(move |value| Message::ToleranceDialogField(
Field::DatumValue(index, value)
))
.size(12)
.padding([3, 6])
.width(Length::Fixed(212.0)),
material_picker(index, true, &entry.material),
]
.spacing(6)
.align_y(iced::Center),
)
},
);
let datums = panel(t!("Datum references").into_owned(), datum_rows.into());
let additions = panel(
t!("Additional information").into_owned(),
column![
row![
text(t!("Projected height"))
.size(11)
.style(muted)
.width(Length::Fixed(112.0)),
text_input(EMPTY, &state.projected_height)
.on_input(|value| Message::ToleranceDialogField(Field::ProjectedHeight(value)))
.size(12)
.padding([3, 6])
.width(Length::Fixed(125.0)),
checkbox(state.projected_zone)
.on_toggle(|value| Message::ToleranceDialogToggle(
Toggle::ProjectedZone(value)
))
.size(14),
text(t!("Projected tolerance zone")).size(11),
]
.spacing(6)
.align_y(iced::Center),
row![
text(t!("Datum identifier"))
.size(11)
.style(muted)
.width(Length::Fixed(112.0)),
text_input(EMPTY, &state.datum_identifier)
.on_input(|value| Message::ToleranceDialogField(Field::DatumIdentifier(value)))
.size(12)
.padding([3, 6])
.width(Length::Fixed(212.0)),
]
.spacing(6)
.align_y(iced::Center),
]
.spacing(6)
.into(),
);
let mut actions = row![
Space::new().width(Fill),
button(text(t!("Cancel")).size(12))
.on_press(Message::CloseModal)
.padding([4, 12]),
]
.spacing(6)
.align_y(iced::Center);
if state.editing.is_some() {
actions = actions.push(
button(text(t!("Apply")).size(12))
.on_press_maybe(state.is_valid().then_some(Message::ToleranceDialogApply))
.padding([4, 12]),
);
}
actions = actions.push(
button(text(t!("OK")).size(12))
.on_press_maybe(state.is_valid().then_some(Message::ToleranceDialogOk))
.style(button::primary)
.padding([4, 12]),
);
column![symbol, tolerances, datums, additions, actions]
.spacing(8)
.padding(10)
.width(sizing.width)
.into()
}

View file

@ -3,6 +3,7 @@ pub mod block_palette;
pub mod layout_manager;
pub mod layer_state_manager;
pub mod drawing_units;
pub mod geometric_tolerance;
pub mod drafting_settings;
pub mod layer_translator;
pub mod plot;