diff --git a/Cargo.lock b/Cargo.lock index fb8d159e..fc84a9cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=db73dab#db73dab540dbfb690c93f4416014262e6cf7bf42" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed#788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=895f8b7#895f8b7b4d53845608689ead46c1a122e352b79b" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b2b1d4b#b2b1d4b0cd7def2d26a0d519991fc89a89f916d3" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 763b322b..267e1e81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ 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 = "db73dab", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "895f8b7", features = ["acis", "offset"] } +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" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -60,6 +60,9 @@ ashpd = { version = "0.13.13", default-features = false, features = ["async-io", iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } 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 = "788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed" } + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } meshopt = "0.6.2" diff --git a/assets/icons/extrude.svg b/assets/icons/extrude.svg new file mode 100644 index 00000000..d864bce3 --- /dev/null +++ b/assets/icons/extrude.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/loft.svg b/assets/icons/loft.svg new file mode 100644 index 00000000..99e9cf62 --- /dev/null +++ b/assets/icons/loft.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/polysolid.svg b/assets/icons/polysolid.svg new file mode 100644 index 00000000..8532c376 --- /dev/null +++ b/assets/icons/polysolid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/presspull.svg b/assets/icons/presspull.svg new file mode 100644 index 00000000..189e6909 --- /dev/null +++ b/assets/icons/presspull.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/pyramid3d.svg b/assets/icons/pyramid3d.svg new file mode 100644 index 00000000..a7d26f91 --- /dev/null +++ b/assets/icons/pyramid3d.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/revolve.svg b/assets/icons/revolve.svg new file mode 100644 index 00000000..48073ba9 --- /dev/null +++ b/assets/icons/revolve.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/sweep.svg b/assets/icons/sweep.svg new file mode 100644 index 00000000..4db7d373 --- /dev/null +++ b/assets/icons/sweep.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/logo.svg b/assets/logo.svg index e6bdb30b..7b4fdc6b 100644 --- a/assets/logo.svg +++ b/assets/logo.svg @@ -1,150 +1,21 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/assets/ocad.pgp b/assets/ocad.pgp index 7c1598cd..84951d13 100644 --- a/assets/ocad.pgp +++ b/assets/ocad.pgp @@ -21,6 +21,7 @@ SPL, *SPLINE H, *HATCH BH, *HATCH HE, *HATCHEDIT +HB, *HATCHTOBACK BO, *BOUNDARY GD, *GRADIENT XL, *XLINE diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md new file mode 100644 index 00000000..20e77acc --- /dev/null +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -0,0 +1,264 @@ +# `ocs_plugin_api` — Internal Architecture + +Internal-facing architecture documentation for `crates/ocs_plugin_api`. For plugin-author tutorials see [`README.md`](./README.md); for host/plugin integration design goals see [`../../docs/plugin-architecture.md`](../../docs/plugin-architecture.md). + +--- + +## Overview + +`ocs_plugin_api` is the stable, semver-versioned contract between the Open CAD Studio host and add-on plugins. The crate is intentionally split into two tiers: + +| Tier | Feature | Dependencies | Purpose | +|------|---------|--------------|---------| +| **Core** | default | none | `PluginManifest`, `ApiVersion`, ribbon vocabulary, type registry, version info. Engine crates and external tooling can depend on this cheaply. | +| **Runtime host surface** | `host` | `acadrust`, `interprocess`, `memmap2`, `rkyv`, etc. | `HostApi`, `BuiltinPlugin`, out-of-process spawn, IPC, shared-memory snapshots, runner. | + +The host and each plugin run in separate OS processes. The host re-executes itself in `--ocs-plugin-runner` mode, the runner dynamically loads the plugin `cdylib`, and the two sides talk over a local socket. Process isolation keeps plugin crashes from affecting the host or other plugins. + +--- + +## Module map + +| Module | Responsibility | +|--------|----------------| +| [`src/manifest.rs`](src/manifest.rs) | `API_VERSION`, `API_VERSION_MIN_SUPPORTED`, `ApiVersion` compatibility, runtime `OCS_PLUGIN_MAX_API_VERSION` gate. | +| [`src/ribbon`](src/ribbon) | `CadModule` trait and plain-data ribbon types (`RibbonGroup`, `ToolDef`, `IconKind`, `ModuleEvent`). | +| [`src/host.rs`](src/host.rs) | `HostApi` plugin-facing runtime trait, `BuiltinPlugin` entry-point trait, `export_plugin!` macro, `HostNotification`, `PluginNotification`. | +| [`src/ipc/mod.rs`](src/ipc/mod.rs) | IPC layer public exports. | +| [`src/ipc/protocol.rs`](src/ipc/protocol.rs) | V2/V3 request/response enums (`HostRequest`, `HostResponse`, `PluginRequest`, `PluginResponse`), `RunnerHandshake`, `PLUGIN_TOKEN_ENV`. | +| [`src/ipc/transport.rs`](src/ipc/transport.rs) | Length-framed bincode send/recv over `interprocess::local_socket::Stream`. | +| [`src/ipc/client.rs`](src/ipc/client.rs) | Plugin-side V2/V3 `IpcClient` and `PluginHostApi` proxy. | +| [`src/ipc/server.rs`](src/ipc/server.rs) | Host-side handler that applies a `PluginRequest` to a `HostApi` implementation. | +| [`src/ipc/v4`](src/ipc/v4) | Multiplexed V4 protocol: frames, notifications, correlation ids. | +| [`src/ipc/proxy.rs`](src/ipc/proxy.rs) | Optional TCP request proxy for plugin child processes. | +| [`src/process.rs`](src/process.rs) | `PluginProcess` lifecycle: spawn, handshake, call, timeouts, `NullHost`, io draining. | +| [`src/process/manager.rs`](src/process/manager.rs) | `PluginManager`: owns loaded plugins, dispatch routing, notification broadcast, V4 gating. | +| [`src/process/v4.rs`](src/process/v4.rs) | Host-side V4 connection and reader thread. | +| [`src/runner.rs`](src/runner.rs) | Runner entry point and V2/V3 vs V4 request loops; loads cdylib via `libloading`. | +| [`src/shm.rs`](src/shm.rs) | Shared-memory document snapshot layout, `DocumentSnapshotStore`, `SharedDocumentReader`, V3/V4 view data. | +| [`src/host_v4.rs`](src/host_v4.rs) | Host-side per-tab V4 snapshot manager. | +| [`src/type_registry.rs`](src/type_registry.rs) / [`src/type_registry_types.rs`](src/type_registry_types.rs) | Build-time `serde-reflection` registry, embedded JSON, schema types. | +| [`src/version_info.rs`](src/version_info.rs) | Embedded version metadata and acadrust source-compatibility helper. | + +--- + +## Versioning and ABI stability + +- `API_VERSION` (currently `4`) is the host's advertised major. +- `API_VERSION_MIN_SUPPORTED` (currently `2`) is the oldest plugin major the host loads. +- `OCS_PLUGIN_MAX_API_VERSION` can cap the accepted major at runtime (e.g. `3` to disable V4). +- A plugin built against major `N` runs on a host whose major is `>= N` because new vtable entries and enum variants are appended at the end. +- V4 introduces the **acadrust gate**: plugins targeting API v4 or later must resolve the same `acadrust` source as the host (see [`src/version_info.rs`](src/version_info.rs)). + +The runtime enforces two gates: + +1. `ocs_plugin_api_version()` exported by the cdylib must be within `[API_VERSION_MIN_SUPPORTED, effective_max_api_version()]`. +2. For v4+, `acadrust_sources_compatible(host_acadrust_source(), plugin_acadrust_source)` must be true. + +--- + +## Process model + +```mermaid +sequenceDiagram + participant PM as PluginManager (host) + participant PP as PluginProcess (host) + participant R as Runner child + participant L as Plugin cdylib + + PM->>PP: spawn(cdylib_path, host) + PP->>PP: create local socket listener + PP->>R: spawn --ocs-plugin-runner + R->>L: unsafe { load(cdylib_path) } + L-->>R: Box + alt API v4 + R->>PP: connect + RunnerHandshake::TokenV4 + PP->>PP: verify token + V4 protocol/API gate + else API v2/v3 + R->>PP: connect + RunnerHandshake::Token + PP->>PP: verify token + end + PP->>R: HostToPlugin::Request(GetManifest) + R->>L: manifest() + L-->>R: PluginManifest { api_version } + R-->>PP: HostResponse::Manifest + PP->>PP: verify api_version + acadrust gate + alt accepted + PP-->>PM: success, keep alive + else rejected + PP->>R: Shutdown + PP-->>PM: error + end +``` + +Key invariants: + +- The runner process is the host binary re-executed with special CLI args, so runner and host share the same `ocs_plugin_api` build. +- The plugin cdylib is `dlopen`-ed inside the runner, not the host. +- A pre-shared token (`OCS_PLUGIN_TOKEN`) authenticates the runner to the host. +- `PluginProcess` owns the `Child` handle and a reader thread; `PluginManager` owns the loaded plugins. + +--- + +## Wire protocols + +### V2/V3: request/response over a single socket + +A single bidirectional socket carries both directions. While the host waits for a response to a `HostRequest`, it may receive nested `PluginRequest`s from the runner and handles them inline. + +```mermaid +sequenceDiagram + participant H as Host + participant R as Runner + participant P as BuiltinPlugin + + H->>R: HostToPlugin::Request(Dispatch { cmd: "LINE" }) + R->>P: dispatch(host, "LINE") + P->>R: PluginToHost::Request(AddEntity(...)) + R->>H: HostToPlugin::Request(AddEntity(...)) + H-->>R: PluginToHost::Response(Handle) + R-->>P: PluginResponse::Handle + P-->>R: true + R-->>H: PluginToHost::Response(Bool(true)) +``` + +Compatibility rule: new variants are appended at the end of `HostRequest`, `HostResponse`, `PluginRequest`, and `PluginResponse`, preserving bincode discriminant indices for old plugins. + +### V4: multiplexed frames + +V4 keeps the V3 request vocabulary but multiplexes requests, responses, and best-effort notifications over one local socket. Every request/response carries a correlation `id`; notifications carry an optional `command_id`. + +```mermaid +sequenceDiagram + participant H as Host + participant R as V4 Runner + participant P as BuiltinPlugin + + H->>R: HostToPluginV4::Notification(DocumentChangedV4 { tab_id }) + H->>R: HostToPluginV4::Request { id: 7, ExecuteCode } + R->>P: start_execute_code(host, 7, ...) + P-->>R: true + P->>R: Notification(Output { text: "..." }) + R->>H: PluginToHostV4::Notification(Output) + P->>R: Response { id: 7, result } + R->>H: PluginToHostV4::Response { id: 7, CodeExecutionResult } +``` + +Key types in [`src/ipc/v4/protocol.rs`](src/ipc/v4/protocol.rs): + +- `HostToPluginV4`: `Request { id, HostRequest }`, `Response { id, PluginResponse }`, `Notification`. +- `PluginToHostV4`: `Request { id, tab_id, PluginRequest }`, `Response { id, HostResponse }`, `Notification`. +- `NotificationEnvelope`: `{ command_id: Option, payload: T }`. + +Notifications are best-effort: per-process errors are logged, not propagated. Unknown host-notification discriminants deserialize to `HostNotification::Unknown(raw)` so a newer host does not crash an older plugin. + +--- + +## Shared-memory document views + +V3 and V4 avoid cloning the full `CadDocument` over IPC for large reads. The host owns a memory-mapped file; the plugin maps it read-only. + +```mermaid +sequenceDiagram + participant P as Plugin + participant R as Runner + participant H as Host + participant S as DocumentSnapshotStore + + P->>R: request(OpenDocumentView) + R->>H: PluginRequest::OpenDocumentView + H->>S: DocumentSnapshotStore::new / publish + S-->>H: DocumentViewInfo { path, version } + H-->>R: PluginResponse::DocumentView { path, version } + R-->>P: DocumentViewInfo + P->>S: SharedDocumentReader::open(path) + S-->>P: read-only mapping + loop host publishes new version + H->>S: publish(data) + S->>S: swap active segment, increment version + P->>S: read() + check version + end + P->>P: close reader + H->>S: close(tab_id) +``` + +- [`src/shm.rs`](src/shm.rs) defines the generic double-buffered control page and the `SnapshotData` trait. Implementations live in the same file for V3 (`DocumentViewData`) and V4 (`DocumentViewDataV4`). +- [`src/host_v4.rs`](src/host_v4.rs) keeps a global `HostV4SnapshotManager` keyed by `tab_id`, so each document tab has its own snapshot. +- Segment size defaults to 16 MiB and is configurable via `OCS_V4_SNAPSHOT_SEGMENT_SIZE` (minimum 1 MiB). + +--- + +## Notifications + +- **Host → plugin:** `HostNotification` in [`src/host.rs`](src/host.rs). V4-only delivery via `PluginManager::broadcast_notification`. Currently emitted examples: `DocumentChangedV4`, `SelectionChangedV4`, `DocumentTabClosed`. +- **Plugin → host:** `PluginNotification` in [`src/host.rs`](src/host.rs). Examples: `Output`, `Progress`, `Log`. Handler installed via `PluginManager::set_notification_handler`. + +Broadcasting is V4-gated inside `PluginManager::broadcast_notification`: V2/V3 processes are skipped. The handler runs on the V4 reader thread and must not block. + +--- + +## Error handling and timeouts + +| Kind | Default | Env var | Behavior | +|------|---------|---------|----------| +| Spawn / connect timeout | 30 s | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | Host aborts spawn, plugin is not loaded. | +| Per-call timeout | 30 s | `OCS_PLUGIN_CALL_TIMEOUT_SECS` | Host kills runner, plugin marked dead. Floor timeouts apply to `GetManifest`/`GetRibbon`, `Dispatch`, and interactive events. | +| Oversized message | — | — | Transport rejects messages > 64 MiB with `TransportError::TooLarge`. | +| Malformed message | — | — | `bincode` deserialize error; V4 reader logs and continues if possible. | +| Panic in plugin code | — | — | Caught by `catch_unwind` in the runner; converted to `HostResponse::Error`. | +| Dead runner | — | — | Detected via `try_wait` on next dispatch or ribbon rebuild; plugin dropped. | + +--- + +## Embedded metadata + +The crate embeds two JSON blobs at build time: + +- **Type registry** (`OUT_DIR/type_registry.json`) — a language-binding-friendly schema for a curated allow-list of `acadrust` types, generated by `serde-reflection` in `build.rs`. See [`src/type_registry.rs`](src/type_registry.rs). +- **Version info** (`OUT_DIR/version_info.json`) — host version, `ocs_plugin_api` version, `acadrust` version and source, API versions, build timestamp. See [`src/version_info.rs`](src/version_info.rs). + +Both are accessible without enabling the `host` feature. + +--- + +## Security / isolation boundaries + +- **Process isolation:** plugin code runs in a child process. +- **Token handshake:** `OCS_PLUGIN_TOKEN` is generated per spawn and checked before accepting the runner connection. +- **C-ABI export:** plugins expose only two symbols, `ocs_plugin_api_version` and `ocs_plugin_register`, defined by `export_plugin!`. +- **Message size cap:** 64 MiB per length-framed message. +- **Shared memory:** plugin maps snapshots read-only; the host controls publish/close. + +This is defense-in-depth, not a sandbox: plugins execute native code. + +--- + +## Failure modes + +| Scenario | Result | +|----------|--------| +| Plugin panics during dispatch | Runner catches, returns error response, process stays alive. | +| Call timeout | Host kills runner, plugin marked dead. | +| Spawn timeout or crash | Spawn fails; plugin not loaded; error surfaced in UI/logs. | +| Version mismatch | Host refuses plugin before running plugin code. | +| Acadrust source mismatch (v4+) | Host refuses plugin to avoid binary incompatibilities. | +| Unknown notification discriminant | Deserializes to `HostNotification::Unknown`; plugin can ignore. | + +--- + +## Open tasks + +- **Reduce broadcast notification clone/serialize overhead.** `PluginManager::broadcast_notification` clones the `HostNotification` (including its `Vec`) once per loaded V4 plugin, and `transport::send` re-serializes the payload into a new `Vec` per plugin. Fixing this properly requires either a public API change (`Arc>` in `HostNotification`) or a new bytes-oriented broadcast path in the IPC layer. It only matters with many plugins plus very large selections. + +--- + +## Cross-references + +- Plugin-author quick start: [`README.md`](./README.md) +- Host/plugin integration spec: [`../../docs/plugin-architecture.md`](../../docs/plugin-architecture.md) +- Plugin template: [`../../docs/plugin-template/`](../../docs/plugin-template/) +- Process spawn: [`src/process.rs`](src/process.rs) +- Process manager: [`src/process/manager.rs`](src/process/manager.rs) +- Runner loop: [`src/runner.rs`](src/runner.rs) +- V4 protocol: [`src/ipc/v4/protocol.rs`](src/ipc/v4/protocol.rs) +- Shared memory: [`src/shm.rs`](src/shm.rs) diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index bba21d9c..fb3a0f1a 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db73dab", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -37,7 +37,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db73dab", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_plugin_api/README.md b/crates/ocs_plugin_api/README.md index 93951816..25021c8e 100644 --- a/crates/ocs_plugin_api/README.md +++ b/crates/ocs_plugin_api/README.md @@ -120,9 +120,10 @@ The frame format supports: - Bi-directional, best-effort notifications (`NotificationEnvelope`) Because the socket is full-duplex, the host can push -`HostNotification::DocumentChanged` or `InputLine` to a plugin at any time, and -the plugin can stream `PluginNotification::Output`/`Progress`/`Log` back while a -long command is still running. +`HostNotification::DocumentChangedV4`, `SelectionChangedV4`, or +`DocumentTabClosed` to a plugin at any time, and the plugin can stream +`PluginNotification::Output`/`Progress`/`Log` back while a long command is still +running. ```mermaid sequenceDiagram @@ -420,7 +421,8 @@ std::thread::spawn(move || { ### Further reading +- Internal architecture & invariants: [`ARCHITECTURE.md`](./ARCHITECTURE.md) +- Host/plugin integration spec: [`../../docs/plugin-architecture.md`](../../docs/plugin-architecture.md) - Plugin template: [`../../docs/plugin-template`](../../docs/plugin-template) - Plugin marketplace registry: [`../../plugins/README.md`](../../plugins/README.md) - REPL design notes: [`../DESIGN_REPL.md`](../DESIGN_REPL.md) -- Python REPL plugin: [`../ocs_python_repl/README.md`](../ocs_python_repl/README.md) diff --git a/crates/ocs_plugin_api/REPORT.md b/crates/ocs_plugin_api/REPORT.md deleted file mode 100644 index 1509fd1c..00000000 --- a/crates/ocs_plugin_api/REPORT.md +++ /dev/null @@ -1,193 +0,0 @@ -# Out-of-Process Plugin Architecture — `ocs_plugin_api` - -**Status:** Design proposal -**Scope:** `crates/ocs_plugin_api` only (minimal host wiring, no plugin changes, no new crate) - ---- - -## 1. Problem - -The host currently loads external add-ons as `cdylib` libraries into its own process via `libloading`. `panic::catch_unwind` catches Rust panics, but a plugin can still corrupt host memory, segfault, or deadlock the UI thread. The fix is to run each plugin as a separate OS process and mediate all interaction through IPC. - ---- - -## 2. Design - -`ocs_plugin_api` becomes a dual-use library: - -- **Plugin side:** unchanged API surface (`BuiltinPlugin`, `HostApi`, `CadModule`, `export_plugin!`). -- **Host side:** runtime that spawns plugin processes and handles their IPC requests. - -Plugins remain `cdylib`s. The host spawns **itself** in runner mode (`--ocs-plugin-runner `) to load each cdylib in a child process and bridge to the host over `interprocess::local_socket`. The runner implementation lives inside `ocs_plugin_api` as a library module; no separate helper binary is needed, so the runner and host are always the same build and cannot get out of sync at deployment time. - ---- - -## 3. Constraints - -| Constraint | Handling | -|---|---| -| Plugin API unchanged | Trait/type signatures preserved. `document()` / `document_mut()` keep their signatures but return a local cached copy, so `API_VERSION` bumps to 3. | -| Only `ocs_plugin_api` modified | All new code lives here. The host needs only minimal call-site wiring in `src/plugin/external.rs`, `src/plugin/registry.rs`, and `src/app/plugin_host.rs`. | -| No new crate | Runner code lives inside `ocs_plugin_api`; the host executable serves as the runner process. | -| Platform-independent | `interprocess::local_socket` uses named pipes on Windows and Unix domain sockets elsewhere; self-spawning works on every host target. | - ---- - -## 4. Architecture - -```text -Host process Plugin process -┌─────────────────┐ local socket ┌─────────────────┐ -│ HostSession │◄───────────────►│ HostApi proxy │ -│ (document, UI) │ bincode frames │ (sends RPCs) │ -└─────────────────┘ └─────────────────┘ - ▲ │ - │ ▼ - PluginManager cdylib loaded - (spawn / kill / by host in - supervise) runner mode -``` - -### 4.1 IPC protocol - -All messages are length-framed and serialized with `bincode`. - -**Host → plugin:** - -- `GetManifest`, `GetRibbon` -- `Dispatch { cmd: String }` -- `InteractiveEvent { command_id, event }` -- `Shutdown` - -**Plugin → host:** - -- `PushInfo` / `PushOutput` / `PushError` -- `AddEntity(SerializedEntity)` → `Handle` -- `BumpGeometry`, `PushUndo`, `SetDirty` -- `ReadRecord`, `WriteRecord`, `RemoveRecord` -- `StartInteractive`, `PollInteractive` -- `DocumentSnapshot` → `SerializedDocument` - -`SerializedEntity`, `SerializedRecord`, and `SerializedDocument` are `acadrust` types with `Serialize` / `Deserialize` derived. - -### 4.2 Plugin-side runtime - -`OpenCADStudio --ocs-plugin-runner `: - -1. Loads the cdylib, validates `ocs_plugin_api_version`, calls `ocs_plugin_register`. -2. Connects to the host socket and answers `GetManifest` / `GetRibbon`. -3. Runs a request loop: dispatch commands, forward interactive events. - -`PluginHostApi` implements `HostApi` by sending RPCs. `document()` / `document_mut()` return a local copy fetched from `DocumentSnapshot`; mutations are **not** automatically synced back. Plugins use `add_entity`, `write_record`, etc. for host-visible changes. - -### 4.3 Host-side runtime - -`ocs_plugin_api::process` provides: - -- `PluginProcess::spawn(cdylib_path)` — creates a socket, launches the host executable in runner mode, accepts its connection. -- `PluginManager` — spawn all discovered plugins, supervise, kill. -- `serve_plugin_connection(stream, &mut dyn HostApi)` — host-side request handler. - -The host creates a `HostSession` and passes it to `serve_plugin_connection`; existing document/undo logic is reused. - -### 4.4 Ribbon - -Ribbon types use `&'static str`, which cannot cross a socket. Define owned equivalents in `ocs_plugin_api::ribbon::owned` and convert for IPC. The host reconstructs `RibbonGroup` once per load (e.g., by leaking the owned strings), avoiding changes to ribbon rendering code. - -### 4.5 Failure handling - -| Failure | Behavior | -|---|---| -| Plugin crash / hang / malformed message | Host marks plugin dead, drops its ribbon tab, logs the error, and continues running. | -| Plugin panics | Caught inside the runner; an error response is returned to the host. | -| Spawn failure | Reported through `PluginManager` and shown in the Plugin Manager. | - ---- - -## 5. Host Integration Points - -1. **`src/plugin/external.rs`** — replace `libloading`-based `LoadedPlugin` with `PluginProcess::spawn`. -2. **`src/plugin/registry.rs`** — use `PluginProcess` for ribbon collection and command dispatch. -3. **`src/app/plugin_host.rs`** — add an IPC request bridge that maps incoming messages to `HostSession` calls. - -No changes to `docs/plugin-template` or any other plugin. - ---- - -## 6. Crate Changes - -### 6.1 New files inside `crates/ocs_plugin_api` - -```text -src/ - ipc/ - protocol.rs # HostRequest / PluginRequest / PluginResponse - transport.rs # framed read/write over local_socket - client.rs # plugin-side IpcClient + PluginHostApi - server.rs # host-side serve_plugin_connection - process.rs # PluginProcess / PluginManager - runner.rs # plugin runner logic invoked by host in runner mode -``` - -### 6.2 Dependencies - -Add under the existing `host` feature: - -```toml -[dependencies] -interprocess = { version = "2", optional = true } -serde = { version = "1", features = ["derive"], optional = true } -bincode = { version = "1", optional = true } -thiserror = { version = "1", optional = true } -libloading = { version = "0.8", optional = true } - -[features] -host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading"] -``` - ---- - -## 7. API Version - -Bump `API_VERSION` to `3` because `document()` / `document_mut()` semantics change from direct host references to local cached copies. v2 plugins are refused as usual; plugin authors recompile with `ApiVersion::CURRENT`. - ---- - -## 8. Implementation Plan - -1. Add dependencies to `Cargo.toml`. -2. Implement framed transport and protocol messages. -3. Implement runner logic in `runner.rs`. -4. Implement `PluginHostApi` proxy. -5. Implement host-side server and `PluginManager`. -6. Add owned ribbon conversions. -7. Wire the three host call sites and add `--ocs-plugin-runner` dispatch in `src/main.rs`. -8. Bump `API_VERSION` and update `docs/plugin-architecture.md`. - ---- - -## 9. Testing - -- **Unit:** protocol round-trip, ribbon conversion, proxy request emission. -- **Integration:** spawn a test plugin, verify dispatch and interactive command round-trip, kill the process and confirm the host survives. -- **Host:** update registry tests once `LoadedPlugin` is replaced by `PluginProcess`. - ---- - -## 10. Compliance with `AGENT.md` - -| Requirement | Status | -|---|---| -| `ocs_plugin_api` is a library, not a plugin | Yes | -| Plugin API source-compatible | Yes; signatures are unchanged. `document()` semantics change is gated by v3. | -| Separate processes + failure management | Yes | -| Platform-independent `interprocess` IPC | Yes | -| Only `ocs_plugin_api` modified | Code: yes. Host needs minimal wiring; unavoidable because `OpenCADStudio` / `HostSession` are host-private. | -| No new crate | Yes | -| Memory / process isolation | Yes | - ---- - -## 11. Summary - -`ocs_plugin_api` absorbs the plugin runtime: the host spawns itself in runner mode to load each cdylib in its own process, and all host/plugin interaction is serialized over `interprocess` local sockets. Plugin API signatures stay intact, host changes are limited to a few call sites, and no new crate is introduced. diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index eb2220b9..f146c23a 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -69,6 +69,8 @@ pub enum HostNotification { DocumentChangedV4 { tab_id: u64, version: u64 }, /// V4 tab closed notification. Discriminant 6. DocumentTabClosed { tab_id: u64 }, + /// V4 selection changed for a specific tab. Discriminant 7. + SelectionChangedV4 { tab_id: u64, handles: Vec }, /// Fallback for notification variants added in future minor revisions. /// Carries the raw bincode payload so an older peer can ignore it without /// failing deserialization. @@ -112,6 +114,13 @@ impl Serialize for HostNotification { bincode::serialize_into(&mut bytes, tab_id) .map_err(serde::ser::Error::custom)?; } + HostNotification::SelectionChangedV4 { tab_id, handles } => { + bytes.push(7); + bincode::serialize_into(&mut bytes, tab_id) + .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, handles) + .map_err(serde::ser::Error::custom)?; + } HostNotification::Unknown(raw) => bytes.extend_from_slice(raw), } bytes.serialize(serializer) @@ -146,6 +155,9 @@ impl<'de> Deserialize<'de> for HostNotification { 6 => bincode::deserialize(rest) .map(|tab_id| HostNotification::DocumentTabClosed { tab_id }) .map_err(serde::de::Error::custom), + 7 => bincode::deserialize(rest) + .map(|(tab_id, handles)| HostNotification::SelectionChangedV4 { tab_id, handles }) + .map_err(serde::de::Error::custom), _ => Ok(HostNotification::Unknown(bytes)), } } diff --git a/crates/ocs_plugin_api/src/host_v4.rs b/crates/ocs_plugin_api/src/host_v4.rs index c6341338..3674ea8f 100644 --- a/crates/ocs_plugin_api/src/host_v4.rs +++ b/crates/ocs_plugin_api/src/host_v4.rs @@ -3,6 +3,12 @@ //! Maintains a per-tab shared-memory document view keyed by the stable //! `DocumentTab.id`. The manager is used by `src/plugin/v4_support.rs` to //! open, publish, and close V4 snapshots independently of the V3 reader path. +//! +//! [`manager`] returns a global `Mutex`-guarded [`HostV4SnapshotManager`]. +//! [`HostV4SnapshotManager::open`] lazily creates a [`DocumentSnapshotStore`] for +//! a tab; [`HostV4SnapshotManager::publish`] updates an existing store; +//! [`HostV4SnapshotManager::close`] drops it. Segment size defaults to 16 MiB +//! and can be overridden with `OCS_V4_SNAPSHOT_SEGMENT_SIZE`. use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; diff --git a/crates/ocs_plugin_api/src/ipc/mod.rs b/crates/ocs_plugin_api/src/ipc/mod.rs index 5ea05e15..44e7e02e 100644 --- a/crates/ocs_plugin_api/src/ipc/mod.rs +++ b/crates/ocs_plugin_api/src/ipc/mod.rs @@ -2,6 +2,21 @@ //! //! Built only with the `host` feature because it needs `acadrust`-typed //! messages and the plugin runner binary. +//! +//! Submodules: +//! +//! - [`protocol`](crate::ipc::protocol) — V2/V3 request/response enums and the +//! initial runner handshake. +//! - [`transport`](crate::ipc::transport) — length-framed bincode over a local +//! socket; enforces `MAX_MESSAGE_SIZE`. +//! - [`client`](crate::ipc::client) — plugin-side V2/V3 client and `HostApi` +//! proxy. +//! - [`server`](crate::ipc::server) — host-side handler that applies a +//! `PluginRequest` to a `HostApi` implementation. +//! - [`v4`](crate::ipc::v4) — multiplexed V4 frames, notifications, and +//! correlation ids. +//! - [`proxy`](crate::ipc::proxy) — optional TCP proxy so plugin child +//! processes can forward requests to the host. #[cfg(feature = "host")] pub mod client; diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index c8735a30..5b494026 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -4,6 +4,16 @@ //! (expecting a response) or a response (to a previous request). This lets the //! host handle plugin RPCs inline while it waits for the result of a host→plugin //! request such as `Dispatch`, avoiding the need for two sockets or threads. +//! +//! Compatibility rule: new variants are appended at the end of every public enum +//! (`HostRequest`, `HostResponse`, `PluginRequest`, `PluginResponse`, +//! `RunnerHandshake`) so older plugins keep their bincode discriminant indices. +//! The V4 additions are a separate frame layer in [`crate::ipc::v4`] and do not +//! alter these enums. +//! +//! The pre-shared runner token is delivered through [`PLUGIN_TOKEN_ENV`] +//! (`OCS_PLUGIN_TOKEN`). The runner must present the same token immediately +//! after connecting or the host closes the connection. use serde::{Deserialize, Serialize}; diff --git a/crates/ocs_plugin_api/src/ipc/proxy.rs b/crates/ocs_plugin_api/src/ipc/proxy.rs index 1300e18d..e7729fcd 100644 --- a/crates/ocs_plugin_api/src/ipc/proxy.rs +++ b/crates/ocs_plugin_api/src/ipc/proxy.rs @@ -79,6 +79,78 @@ pub fn recv_framed(reader: &mut R) -> R Ok(msg) } +/// Error during a polled read: either an IO error on the stream, or an +/// interrupt reported by the poll callback (e.g. the user pressed Ctrl+C). +#[derive(Debug)] +enum PollError { + Io(ProxyError), + Interrupted(PluginRequestError), +} + +impl From for PollError { + fn from(e: ProxyError) -> Self { + PollError::Io(e) + } +} + +impl From for PluginRequestError { + fn from(e: PollError) -> Self { + match e { + PollError::Io(err) => err.into(), + PollError::Interrupted(err) => err, + } + } +} + +/// Read exactly `buf.len()` bytes from `reader`, calling `poll` whenever no +/// data is currently available. Unlike `read_exact`, this preserves partial +/// progress across short read timeouts so the stream can never get out of sync. +fn read_with_poll( + reader: &mut R, + buf: &mut [u8], + poll: &mut dyn FnMut() -> Result<(), PluginRequestError>, +) -> Result<(), PollError> { + use std::io::ErrorKind; + let mut off = 0; + while off < buf.len() { + match reader.read(&mut buf[off..]) { + Ok(0) => return Err(PollError::Io(ProxyError::Io(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "proxy disconnected", + )))), + Ok(n) => off += n, + Err(e) if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => { + if let Err(err) = poll() { + return Err(PollError::Interrupted(err)); + } + } + Err(e) => return Err(PollError::Io(ProxyError::Io(e))), + } + } + Ok(()) +} + +/// Receive a length-framed bincode message with polling. The length prefix and +/// payload are read incrementally so a timeout never splits a message. +fn recv_framed_with_poll( + reader: &mut R, + poll: &mut dyn FnMut() -> Result<(), PluginRequestError>, +) -> Result { + let mut len_buf = [0u8; 8]; + read_with_poll(reader, &mut len_buf, poll)?; + let len = u64::from_le_bytes(len_buf) as usize; + if len == 0 { + return Err(ProxyError::Empty.into()); + } + if len > MAX_MESSAGE_SIZE { + return Err(ProxyError::TooLarge(len).into()); + } + let mut buf = vec![0u8; len]; + read_with_poll(reader, &mut buf, poll)?; + let msg = bincode::deserialize(&buf).map_err(ProxyError::Encode)?; + Ok(msg) +} + /// Thread-safe [`PluginRequestSender`] implementation that forwards requests /// over a single TCP stream to a request proxy server. pub struct ProxyPluginRequestSender { @@ -113,12 +185,12 @@ impl ProxyPluginRequestSender { /// [`POLL_INTERVAL`] while waiting. This lets Python callers run /// `py.check_signals()` so that `Ctrl+C`/`KeyboardInterrupt` is raised /// promptly instead of waiting for the full socket timeout. + /// Partial reads are preserved across timeouts to keep framing synchronized. pub fn request_with_poll( &self, req: PluginRequest, poll: &mut dyn FnMut() -> Result<(), PluginRequestError>, ) -> Result { - use std::io::ErrorKind; let mut stream = self .stream .lock() @@ -130,21 +202,17 @@ impl ProxyPluginRequestSender { stream .set_read_timeout(Some(POLL_INTERVAL)) .map_err(|e| PluginRequestError(e.to_string()))?; - let result = loop { - match recv_framed(&mut *stream) { - Ok(resp) => break Ok(resp), - Err(ProxyError::Io(e)) - if e.kind() == ErrorKind::WouldBlock - || e.kind() == ErrorKind::TimedOut => - { - if let Err(e) = poll() { - let _ = stream.shutdown(Shutdown::Both); - break Err(e); - } - } - Err(e) => break Err(e.into()), + + let result = match recv_framed_with_poll(&mut *stream, poll) { + Ok(resp) => Ok(resp), + Err(PollError::Interrupted(e)) => { + // A partial response cannot be reused after interruption. + let _ = stream.shutdown(Shutdown::Both); + Err(e) } + Err(PollError::Io(e)) => Err(e.into()), }; + let _ = stream.set_read_timeout(original_timeout); result } @@ -331,4 +399,55 @@ mod tests { .unwrap(); assert!(matches!(resp, PluginResponse::Ok)); } + + /// A reader that returns one byte per call and then `WouldBlock` on the + /// next call, exercising both the incremental read and the poll path. + struct OneByteThenBlock<'a> { + data: &'a [u8], + block_next: bool, + } + + impl<'a> Read for OneByteThenBlock<'a> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.block_next { + self.block_next = false; + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "simulated block", + )); + } + if self.data.is_empty() { + return Ok(0); + } + let n = buf.len().min(1); + buf[..n].copy_from_slice(&self.data[..n]); + self.data = &self.data[n..]; + self.block_next = true; + Ok(n) + } + } + + #[test] + fn recv_framed_with_poll_reads_one_byte_at_a_time() { + let msg = PluginResponse::Bool(true); + let bytes = bincode::serialize(&msg).unwrap(); + let len = bytes.len() as u64; + let mut framed = Vec::with_capacity(8 + bytes.len()); + framed.extend_from_slice(&len.to_le_bytes()); + framed.extend_from_slice(&bytes); + + let mut reader = OneByteThenBlock { + data: &framed, + block_next: false, + }; + let mut polls = 0; + let resp = recv_framed_with_poll(&mut reader, &mut || { + polls += 1; + Ok(()) + }) + .unwrap(); + assert!(matches!(resp, PluginResponse::Bool(true))); + // One byte, one block for every byte except the last (no poll after EOF). + assert_eq!(polls, framed.len() - 1); + } } diff --git a/crates/ocs_plugin_api/src/ipc/transport.rs b/crates/ocs_plugin_api/src/ipc/transport.rs index 69e42ce6..3cc1296d 100644 --- a/crates/ocs_plugin_api/src/ipc/transport.rs +++ b/crates/ocs_plugin_api/src/ipc/transport.rs @@ -1,4 +1,12 @@ //! Length-framed transport over `interprocess::local_socket` streams. +//! +//! Messages are serialized with `bincode`, prefixed by a little-endian `u64` +//! length, and sent over the stream. The receiver parses the length, bounds it +//! against [`MAX_MESSAGE_SIZE`], then deserializes the payload. +//! +//! [`send`] and [`recv`] are synchronous and block until the full frame is read +//! or the peer disconnects. `Disconnected` is returned on clean EOF; `TooLarge` +//! rejects messages that exceed 64 MiB to protect host/runner memory. use std::io::{Read, Write}; diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 6eda5605..a491a6eb 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -761,7 +761,6 @@ impl DocumentReader for EmptyDocumentReader { #[cfg(all(test, feature = "host"))] mod tests { use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::Mutex as StdMutex; use std::thread; use interprocess::local_socket::{ @@ -772,10 +771,9 @@ mod tests { use super::*; use crate::ipc::transport::recv; use crate::ipc::v4::protocol::{HostToPluginV4, PluginToHostV4}; + use crate::test_lock::ENV_LOCK; use acadrust::entities::Point; - static ENV_LOCK: StdMutex<()> = StdMutex::new(()); - fn unique_socket_name() -> String { static COUNTER: AtomicU64 = AtomicU64::new(0); let n = COUNTER.fetch_add(1, Ordering::Relaxed); diff --git a/crates/ocs_plugin_api/src/ipc/v4/mod.rs b/crates/ocs_plugin_api/src/ipc/v4/mod.rs index e67788bd..3a27ac7e 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/mod.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/mod.rs @@ -4,6 +4,15 @@ //! requests, responses, and best-effort notifications over a single local //! socket. The V3 files (`src/ipc/{protocol,client,server}.rs`) are left //! untouched; all V4 logic lives in this module. +//! +//! Submodules: +//! +//! - [`protocol`](crate::ipc::v4::protocol) — V4 wire frames, notification +//! envelopes, and correlation ids. +//! - [`client`](crate::ipc::v4::client) — plugin-side V4 client loop and +//! notification dispatch. +//! - [`server`](crate::ipc::v4::server) — host-side V4 connection and reader +//! thread. pub mod client; pub mod protocol; diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 2d339b5d..34e97c2e 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -1,4 +1,23 @@ //! V4 wire frames and envelope types. +//! +//! V4 replaces the simple request/response pipe with a multiplexed frame layer. +//! Every request and response carries a monotonically increasing correlation +//! `id` so a plugin can send/receive out of order. Notifications carry an +//! optional `command_id` so a plugin can associate a host notification with the +//! long-running command that caused it. +//! +//! The two frame enums are: +//! +//! - [`HostToPluginV4`] — host → runner requests, responses to runner requests, +//! and host notifications. +//! - [`PluginToHostV4`] — runner → host requests, responses to host requests, +//! and plugin notifications. +//! +//! Notifications are wrapped in [`NotificationEnvelope`] and are best-effort: +//! a process that fails to accept a notification is logged and skipped, but the +//! connection stays alive. Unknown host-notification discriminants deserialize +//! to [`crate::host::HostNotification::Unknown`] so newer host notifications do +//! not break older plugins. use serde::{Deserialize, Serialize}; diff --git a/crates/ocs_plugin_api/src/lib.rs b/crates/ocs_plugin_api/src/lib.rs index e721983e..9b430922 100644 --- a/crates/ocs_plugin_api/src/lib.rs +++ b/crates/ocs_plugin_api/src/lib.rs @@ -20,6 +20,17 @@ //! For binary compatibility, the host and every plugin must resolve the same //! `acadrust` source. The host does this via a `[patch.crates-io]` entry in //! `Cargo.toml`; out-of-tree plugins should copy that exact patch. +//! +//! For internal architecture (process model, wire protocols, versioning policy, +//! failure modes), see `ARCHITECTURE.md` in the crate root. The modules enabled +//! by the `host` feature are: +//! +//! - `host` — plugin/runtime traits and notification types. +//! - `host_v4` — per-tab shared-memory snapshot manager. +//! - `ipc` — transport, V2/V3 protocol, and V4 multiplexed protocol. +//! - `process` — `PluginProcess` and `PluginManager`. +//! - `runner` — child-process runner entry point. +//! - `shm` — shared-memory document views. pub mod manifest; pub mod ribbon; @@ -64,5 +75,15 @@ pub use type_registry::{ }; pub use version_info::get_embedded_version_info_json; +#[cfg(test)] +pub(crate) mod test_lock { + //! Shared lock for tests that mutate process environment variables. + //! + //! Environment variables are global mutable state. Any test that sets or + //! removes an env var must hold this lock for the duration of the mutation + //! so tests in other modules do not observe half-written state. + pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +} + #[cfg(feature = "host")] pub use process::{DispatchResult, PluginError, PluginManager, PluginProcess}; diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index 0d459341..39352e8a 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -1,4 +1,16 @@ -//! Plugin identity and capability declaration. +//! Plugin identity, API version constants, and compatibility rules. +//! +//! This module defines the host ABI major version (`API_VERSION`), the oldest +//! major the host still loads (`API_VERSION_MIN_SUPPORTED`), and the runtime +//! `OCS_PLUGIN_MAX_API_VERSION` gate that lets operators disable newer API +//! majors without rebuilding. +//! +//! Compatibility rule: a plugin compiled against major `N` runs on any host +//! whose major is `>= N`, because new vtable entries and enum variants are +//! appended at the end. `host_accepts_plugin_version` enforces the supported +//! range after applying the runtime cap. +//! +//! See `ARCHITECTURE.md` in the crate root for the full versioning policy. /// Host plugin API version. Bump when the host runtime surface breaks /// compatibility. v2 added `HostApi::start_interactive`. v3 changes @@ -56,12 +68,10 @@ pub fn host_accepts_plugin_version(plugin_major: u32) -> bool { #[cfg(test)] mod tests { - use std::sync::Mutex; + use crate::test_lock::ENV_LOCK; use super::*; - static ENV_LOCK: Mutex<()> = Mutex::new(()); - #[test] fn current_matches_const() { assert_eq!(ApiVersion::CURRENT.major, API_VERSION); diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index 9a26d958..7535af35 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -1,4 +1,22 @@ //! Process management for out-of-process plugins. +//! +//! This module defines the lifecycle of a single plugin process: +//! +//! 1. `PluginProcess::spawn` creates a local socket, spawns the host binary in +//! runner mode (`--ocs-plugin-runner `), and waits for the +//! runner to connect back. +//! 2. The runner presents a pre-shared token via [`crate::ipc::protocol::PLUGIN_TOKEN_ENV`]; +//! the host rejects the connection on mismatch. +//! 3. The host requests the manifest, checks `api_version` and (for v4+) the +//! acadrust source gate, then keeps the process alive. +//! 4. Host → plugin calls (`dispatch`, `execute_code`, interactive events) are +//! sent over the socket with a configurable per-call timeout. +//! 5. Stdout/stderr of the child are drained into `PluginIoLine` records for +//! logging and UI display. +//! +//! `NullHost` is a dummy `HostApi` implementation used for V4 paths that do not +//! supply a real host surface (e.g., interactive events). Timeouts and message +//! size limits are centralized here. use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; @@ -1119,6 +1137,7 @@ mod timeout_tests { }; use crate::ipc::transport::{recv, send}; use crate::ribbon::owned::OwnedPluginManifest; + use crate::test_lock::ENV_LOCK; use acadrust::xdata::ExtendedDataRecord; use acadrust::{CadDocument, EntityType, Handle}; use interprocess::local_socket::{ @@ -1130,8 +1149,6 @@ mod timeout_tests { use std::thread; use std::time::Instant; - static ENV_LOCK: StdMutex<()> = StdMutex::new(()); - struct EmptyReader; impl DocumentReader for EmptyReader { diff --git a/crates/ocs_plugin_api/src/process/manager.rs b/crates/ocs_plugin_api/src/process/manager.rs index d2fec1e4..003f6ff0 100644 --- a/crates/ocs_plugin_api/src/process/manager.rs +++ b/crates/ocs_plugin_api/src/process/manager.rs @@ -1,4 +1,19 @@ //! Process manager for out-of-process plugins. +//! +//! `PluginManager` is the host's owner of every loaded plugin process. It is +//! responsible for: +//! +//! - Spawning plugins via [`PluginProcess::spawn`] and building their ribbon +//! modules. +//! - Routing commands to plugins through [`PluginManager::dispatch`]. +//! - Broadcasting host notifications to every alive V4 plugin via +//! [`PluginManager::broadcast_notification`]. +//! - Surfacing dead plugins and per-plugin errors in [`DispatchResult`]. +//! +//! Notifications are V4-only: `broadcast_notification` skips non-V4 processes. +//! The plugin-to-host notification handler (set via +//! [`PluginManager::set_notification_handler`]) runs on the V4 reader thread +//! and must not block. For heavy work, forward the notification to a channel. use std::path::Path; use std::sync::Arc; diff --git a/crates/ocs_plugin_api/src/process/v4.rs b/crates/ocs_plugin_api/src/process/v4.rs index 9843c3bb..d2fc6015 100644 --- a/crates/ocs_plugin_api/src/process/v4.rs +++ b/crates/ocs_plugin_api/src/process/v4.rs @@ -517,7 +517,7 @@ mod tests { use std::sync::Mutex as StdMutex; - static ENV_LOCK: StdMutex<()> = StdMutex::new(()); + use crate::test_lock::ENV_LOCK; fn set_test_env() { std::env::set_var("OCS_PLUGIN_CALL_TIMEOUT_SECS", "2"); @@ -532,6 +532,13 @@ mod tests { #[test] fn execute_code_timeout_floor_is_at_least_60s_and_env_override() { let _guard = ENV_LOCK.lock().unwrap(); + let prev_call_timeout = std::env::var("OCS_PLUGIN_CALL_TIMEOUT_SECS").ok(); + let prev_test_floor = std::env::var("OCS_PLUGIN_TEST_FLOOR_SECS").ok(); + let prev_execute_timeout = std::env::var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS").ok(); + // Remove the test floor bypass and the call-timeout override so the + // ExecuteCode-specific floor (60 s) is actually exercised. + std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS"); + std::env::remove_var("OCS_PLUGIN_TEST_FLOOR_SECS"); std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"); assert!( request_timeout("ExecuteCode") >= Duration::from_secs(60), @@ -544,7 +551,19 @@ mod tests { Duration::from_secs(120), "env override should be respected" ); - std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"); + + match prev_call_timeout { + Some(v) => std::env::set_var("OCS_PLUGIN_CALL_TIMEOUT_SECS", v), + None => std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS"), + } + match prev_test_floor { + Some(v) => std::env::set_var("OCS_PLUGIN_TEST_FLOOR_SECS", v), + None => std::env::remove_var("OCS_PLUGIN_TEST_FLOOR_SECS"), + } + match prev_execute_timeout { + Some(v) => std::env::set_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS", v), + None => std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"), + } } #[test] diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index 6b4a60c6..03f0097f 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -4,6 +4,18 @@ //! (`--ocs-plugin-runner `). Keeping the runner code inside //! `ocs_plugin_api` means the host only needs to know the CLI contract, not the //! internal plugin-loading and IPC details. +//! +//! Entry points and loops: +//! +//! - [`run`] — loads the cdylib with `libloading`, connects back to the host, +//! and dispatches to either the V2/V3 loop or the V4 loop based on the plugin's +//! declared API version. +//! - `run_v3` — synchronous request/response loop over [`crate::ipc::client::IpcClient`]. +//! - `run_v4` — multiplexed frame loop over [`crate::ipc::v4::client::V4Client`]; +//! drains notifications and dispatches host requests. +//! +//! Panics inside plugin callbacks are caught by `catch_unwind` and converted to +//! error responses so a buggy plugin callback does not tear down the runner. use std::cell::RefCell; use std::collections::HashMap; diff --git a/crates/ocs_plugin_api/src/shm.rs b/crates/ocs_plugin_api/src/shm.rs index ebada4e5..4203ce40 100644 --- a/crates/ocs_plugin_api/src/shm.rs +++ b/crates/ocs_plugin_api/src/shm.rs @@ -8,6 +8,21 @@ //! This module is generic over the payload type so that the V3 simplified view //! and the V4 bincode-per-entity view can share the same double-buffered //! control-page logic. +//! +//! Key types: +//! +//! - [`DocumentSnapshotStore`] — host-side, file-backed double buffer. Call +//! [`DocumentSnapshotStore::publish`] to atomically swap the active segment and +//! increment the version. +//! - [`SharedDocumentReader`] — plugin-side read-only mapping. +//! - [`DocumentViewInfo`] — path + version returned to the plugin so it can +//! open the mapping. +//! - [`DocumentViewData`] / [`DocumentViewDataV4`] — concrete snapshot payloads +//! for V3 and V4. +//! +//! The control page at the start of the mapping stores magic, version, active +//! segment, and length. The two snapshot segments follow it. Publishing writes +//! to the inactive segment, then flips the active segment atomically. use std::fs::OpenOptions; use std::io; diff --git a/crates/ocs_plugin_api/src/type_registry.rs b/crates/ocs_plugin_api/src/type_registry.rs index a563d715..ce55abce 100644 --- a/crates/ocs_plugin_api/src/type_registry.rs +++ b/crates/ocs_plugin_api/src/type_registry.rs @@ -3,7 +3,15 @@ //! The JSON embedded here is produced by tracing a curated allow-list of //! `acadrust` types with `serde-reflection` and mapping the result into a //! stable, language-binding-friendly schema defined in -//! [`crate::type_registry_types`]. +//! [`crate::type_registry_types`]. The registry is embedded via +//! `include_str!(concat!(env!("OUT_DIR"), "/type_registry.json"))` so the +//! `host` feature is not required to read it. +//! +//! The allow-list is intentionally narrow: it excludes generic containers and +//! internal enums such as `EntityType` in favor of concrete serializable structs +//! like `Point`, `Line`, `Circle`, and `MText`. Language bindings and tooling +//! can depend on this schema without pulling the full `acadrust` dependency +//! tree. pub use crate::type_registry_types::*; diff --git a/crates/ocs_plugin_api/src/version_info.rs b/crates/ocs_plugin_api/src/version_info.rs index 1d690c07..35c870cd 100644 --- a/crates/ocs_plugin_api/src/version_info.rs +++ b/crates/ocs_plugin_api/src/version_info.rs @@ -1,4 +1,18 @@ //! 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; /// The embedded version info as a JSON string. pub const EMBEDDED_VERSION_INFO_JSON: &str = @@ -9,6 +23,57 @@ pub fn get_embedded_version_info_json() -> &'static str { EMBEDDED_VERSION_INFO_JSON } +#[derive(Debug, Clone)] +struct VersionInfo { + acadrust_source: String, +} + +pub const ACADRUST_GATE_API_VERSION: u32 = 4; + +/// Returns whether this API version uses the dependency gate. +pub fn uses_acadrust_gate(api_version: u32) -> bool { + api_version >= ACADRUST_GATE_API_VERSION +} + +/// Extracts a string from the generated compact JSON. +fn json_string_value(json: &str, key: &str) -> Option { + let pattern = format!(r#""{key}":""#); + let start = json.find(&pattern)? + pattern.len(); + let end = json[start..].find('"')?; + Some(json[start..start + end].to_string()) +} + +fn parsed_version_info() -> &'static VersionInfo { + static INFO: OnceLock = OnceLock::new(); + INFO.get_or_init(|| VersionInfo { + acadrust_source: json_string_value(EMBEDDED_VERSION_INFO_JSON, "acadrust_source") + .unwrap_or_default(), + }) +} + +/// Returns the host's full `acadrust` Cargo source. +pub fn host_acadrust_source() -> &'static str { + &parsed_version_info().acadrust_source +} + +/// Extracts the full git commit hash from a Cargo source. +pub fn acadrust_source_hash(source: &str) -> Option<&str> { + let hash = source.rsplit('#').next()?; + if hash.len() == 40 && hash.chars().all(|c| c.is_ascii_hexdigit()) { + Some(hash) + } else { + None + } +} + +/// Returns whether two Cargo sources use the same commit. +pub fn acadrust_sources_compatible(a: &str, b: &str) -> bool { + match (acadrust_source_hash(a), acadrust_source_hash(b)) { + (Some(ha), Some(hb)) => ha.eq_ignore_ascii_case(hb), + _ => false, + } +} + #[cfg(test)] mod tests { use super::*; @@ -19,7 +84,7 @@ mod tests { assert!(!json.is_empty()); let value: serde_json::Value = serde_json::from_str(json).expect("valid JSON"); assert!(value.get("ocs_version").is_some()); - assert!(value.get("acadrust_version").is_some()); + assert!(value.get("acadrust_source").is_some()); } #[test] @@ -54,4 +119,57 @@ mod tests { let acadrust = value["acadrust_version"].as_str().expect("acadrust_version string"); assert_eq!(acadrust.split('.').count(), 3); } + + #[test] + fn host_acadrust_source_is_non_empty() { + assert!(!host_acadrust_source().is_empty()); + } + + #[test] + fn dependency_gate_starts_at_api_v4() { + assert!(!uses_acadrust_gate(3)); + assert!(uses_acadrust_gate(4)); + assert!(uses_acadrust_gate(5)); + } + + #[test] + fn extracts_full_hash_from_cargo_source() { + let src = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; + assert_eq!( + acadrust_source_hash(src), + Some("94df2c3f87fa051b16ffc3923f80e9247c85c5fd") + ); + } + + #[test] + fn rejects_malformed_or_missing_hash() { + assert!(acadrust_source_hash("").is_none()); + assert!(acadrust_source_hash("registry+https://crates.io").is_none()); + assert!(acadrust_source_hash("git+https://github.com/foo/bar.git#short").is_none()); + assert!( + acadrust_source_hash("git+https://github.com/foo/bar.git#gggggggggggggggggggggggggggggggggggggggg") + .is_none() + ); + } + + #[test] + fn source_comparison_matches_full_hashes() { + let a = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; + let b = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; + assert!(acadrust_sources_compatible(a, b)); + } + + #[test] + fn source_comparison_detects_mismatch() { + let a = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; + let b = "git+https://github.com/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb"; + assert!(!acadrust_sources_compatible(a, b)); + } + + #[test] + fn source_comparison_case_insensitive() { + let a = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; + let b = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94DF2C3F87FA051B16FFC3923F80E9247C85C5FD"; + assert!(acadrust_sources_compatible(a, b)); + } } diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index ff034dd7..202b5164 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db73dab", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index b3a5e5a4..dc1a8d0a 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -127,6 +127,7 @@ concrete types: | Command line | `push_info`, `push_output`, `push_error` | | Undo / dirty | `push_undo`, `set_dirty` | | Tab | `tab_index()` | +| Notifications (V4) | `on_notification` receives `HostNotification::SelectionChangedV4 { tab_id, handles }` when the active tab's selection changes, plus `DocumentChangedV4` / `DocumentTabClosed`. | ### `export_plugin!` — the C-ABI export @@ -309,7 +310,7 @@ Two timeouts protect the host from a stuck runner: | Timeout | Env var | Default | Floor | |---|---|---|---| -| Spawn (connection) | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | 10 s | — | +| Spawn (connection) | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | 30 s | — | | Per-call | `OCS_PLUGIN_CALL_TIMEOUT_SECS` | 30 s | `GetManifest`/`GetRibbon` ≥ 5 s, `Dispatch` ≥ 10 s, interactive events/prompt/pick ≥ 2 s | A call timeout covers the full round-trip, including any nested plugin→host @@ -402,6 +403,7 @@ Next: | Piece | Location | |-------|----------| | Contract crate + runtime | [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) | +| Internal API architecture | [`crates/ocs_plugin_api/ARCHITECTURE.md`](../crates/ocs_plugin_api/ARCHITECTURE.md) | | Plugin runner implementation | [`crates/ocs_plugin_api/src/runner.rs`](../crates/ocs_plugin_api/src/runner.rs) | | Host spawn logic | [`crates/ocs_plugin_api/src/process.rs`](../crates/ocs_plugin_api/src/process.rs) | | Host plugin integration | `src/plugin/`, `src/app/plugin_host.rs` | diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 54252271..21ff57e8 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = رسم .annotate = تعليقات توضيحية .insert = إدراج - .model = نموذج + .model = النمذجة .layout = تخطيط .manage = إدارة .view = عرض @@ -345,6 +345,7 @@ common = .brightness = السطوع .by-layer = حسب الطبقة .byblock = حسب الكتلة + .bycolor = حسب اللون .bylayer = حسب الطبقة .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: تم تحويل __ocs_fmt_0__ مجسم إلى أسطح. .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: حدد مجسمًا أُنشئ في هذه الجلسة. @@ -1811,14 +1812,24 @@ annotate = .collect-leaders = تجميع الخطوط القائدة .ddedit-select-text-entity = DDEDIT حدد كيان النص: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED أدخل نص البُعد (فارغ = القيمة المقاسة): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED حدد موقع خط البُعد [نص/زاوية]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED حدد أصل خط الامتداد الأول: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED حدد أصل خط الامتداد الثاني [نص/زاوية]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED أدخل نص البُعد المنسق (فارغ = القيمة المقاسة): + .dimaligned-select-object-to-dimension = DIMALIGNED حدد كائنًا لقياسه: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED حدد موقع خط البُعد [Mtext/نص/زاوية]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED حدد أصل خط الامتداد الأول أو اضغط Enter لتحديد كائن: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED حدد أصل خط الامتداد الثاني: .dimaligned-specify-text-angle-degrees = DIMALIGNED حدد زاوية النص (بالدرجات): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR أدخل نص البُعد (فارغ = القيمة المقاسة): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR أدخل نص البُعد المنسق (فارغ = القيمة المقاسة): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR حدد قوسًا أو دائرة أو خطًا، أو حدد رأس زاوية: + .dimangular-select-second-line = DIMANGULAR حدد الخط الثاني: .dimangular-specify-angle-vertex = DIMANGULAR حدد رأس الزاوية: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR حدد موقع قوس البُعد [Mtext/نص/زاوية/ربع]: .dimangular-specify-dimension-arc-location = DIMANGULAR حدد موقع قوس البُعد: .dimangular-specify-first-extension-line-point = DIMANGULAR حدد نقطة خط الامتداد الأول: + .dimangular-specify-quadrant = DIMANGULAR حدد الربع: .dimangular-specify-second-extension-line-point = DIMANGULAR حدد نقطة خط الامتداد الثاني: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR حدد النقطة الثانية لزاوية النص: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR حدد زاوية النص أو النقطة الأولى: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE لم يُعثر على بُعد أساسي. ضع بُعدًا أولًا. .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE حدد أصل خط امتداد ثانٍ (Enter للخروج): .dimbreak-select-dimension-to-break = DIMBREAK حدد البُعد المراد قطعه: @@ -1826,8 +1837,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE لم يُعثر على بُعد أساسي. ضع بُعدًا أولًا. .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE حدد أصل خط امتداد ثانٍ (Enter للخروج): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER أدخل نص البُعد (فارغ = القيمة المقاسة): - .dimdiameter-specify-center-point = DIMDIAMETER حدد نقطة المركز: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER حدد موقع خط البُعد [نص/زاوية]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER حدد قوسًا أو دائرة أو قوسًا متعدد الخطوط: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER حدد موقع خط البُعد [نص متعدد الأسطر/نص/زاوية]: .dimdiameter-specify-point-on-circle = DIMDIAMETER حدد نقطة على الدائرة: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER حدد زاوية النص (بالدرجات): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT أدخل نص التجاوز (فارغ = إعادة للقيمة المقاسة): @@ -1843,10 +1854,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR حدد أصل خط الامتداد الثاني [نص/زاوية]: .dimlinear-specify-text-angle-degrees = DIMLINEAR حدد زاوية النص (بالدرجات): .dimordinate-specify-feature-location = DIMORDINATE حدد موقع المعلم: - .dimordinate-specify-leader-endpoint = DIMORDINATE حدد نقطة نهاية الخط القائد: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE أدخل نص البُعد (فارغ = القيمة المقاسة): + .dimordinate-specify-leader-endpoint = DIMORDINATE حدد نقطة نهاية الخط القائد [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE حدد زاوية النص (بالدرجات): .dimradius-enter-dimension-text-blank-measured = DIMRADIUS أدخل نص البُعد (فارغ = القيمة المقاسة): - .dimradius-specify-center-point = DIMRADIUS حدد نقطة المركز: - .dimradius-specify-dimension-line-location-text = DIMRADIUS حدد موقع خط البُعد [نص/زاوية]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS حدد قوسًا أو دائرة أو قوسًا متعدد الخطوط: + .dimradius-specify-dimension-line-location-text = DIMRADIUS حدد موقع خط البُعد [نص متعدد الأسطر/نص/زاوية]: .dimradius-specify-radius-point = DIMRADIUS حدد نقطة نصف القطر: .dimradius-specify-text-angle-degrees = DIMRADIUS حدد زاوية النص (بالدرجات): .dimspace-enter-value-0-auto = DIMSPACE أدخل القيمة (0 = تلقائي): @@ -1878,6 +1891,7 @@ annotate = البيانات .lower = خفض .mleader-specify-arrowhead-point = MLEADER حدد نقطة رأس السهم: + .mleader-specify-landing-point = MLEADER حدد نقطة خط الإسناد: .mleader-specify-next-point-count-pts-enter-to = MLEADER حدد النقطة التالية [__ocs_arg_count__ نقاط — Enter لوضع النص]: .mleaderadd-select-a-multileader = MLEADERADD حدد خطًا قائدًا متعددًا: .mleaderadd-specify-arrowhead-location = MLEADERADD حدد موقع رأس السهم: @@ -2047,6 +2061,15 @@ insert = .object-s-cut-to-clipboard = قُص __ocs_fmt_0__ كائن إلى الحافظة. model = + .create = إنشاء + .boolean = عمليات منطقية + .pyramid = هرم + .polysolid = مجسّم متعدد + .extrude = بثق + .revolve = تدوير + .loft = تشكيل انتقالي + .sweep = سحب على مسار + .presspull = دفع/سحب .n-specify-center-point = __ocs_arg_n__ حدد نقطة المركز: .n-specify-first-corner = __ocs_arg_n__ حدد الزاوية الأولى: .n-specify-height-enter-for-default = __ocs_arg_n__ حدد الارتفاع : @@ -2347,6 +2370,7 @@ view = .zoom-extents = تكبير إلى النطاقات + .cmd-no-hatch-objects = __ocs_fmt_0__: ┘äϺ Ϭ┘êϼϻ ┘âϺϪ┘åϺϬ Ϭϩ┘ä┘è┘ä. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: لا توجد كائنات نص أو أبعاد. .cmd-not-yet-implemented = __ocs_fmt_0__: لم يُنفّذ بعد. .viewport-s-in-layout = __ocs_fmt_0__ منفذ عرض في التخطيط "__ocs_fmt_1__": @@ -2724,6 +2748,9 @@ properties = .plot-style-table = جدول نمط الطباعة .plot-table-attached-to = جدول الطباعة مرفق بـ .plot-table-type = نوع جدول الطباعة + .color-dependent-plot-styles = أنماط طباعة معتمدة على اللون + .named-plot-styles = أنماط طباعة مسماة + .plot-style-is-locked-to-color-in-color-dependent = نمط الطباعة مقيد باللون في الوضع المعتمد على اللون (CTB) .point-1-x = النقطة 1 X .point-1-y = النقطة 1 Y .point-1-z = النقطة 1 Z diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 5da17064..d4005dbc 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Чертане .annotate = Анотиране .insert = Вмъкване - .model = Модел + .model = Моделиране .layout = Оформление .manage = Управление .view = Изглед @@ -332,6 +332,7 @@ common = .brightness = Яркост .by-layer = По слой .byblock = По блок + .bycolor = По цвят .bylayer = По слой .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: конвертирани __ocs_fmt_0__ тяло/а в повърхност/и. .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: избери тяло, създадено в тази сесия. @@ -1798,14 +1799,24 @@ annotate = .collect-leaders = Събери водачи .ddedit-select-text-entity = DDEDIT Избери текстов обект: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED Въведи текст на оразмеряване (празно = измерена стойност): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Посочи местоположение на линията за оразмеряване [Текст/Ъгъл]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED Посочи начало на първата удължителна линия: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED Посочи начало на втората удължителна линия [Текст/Ъгъл]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED Въведи форматиран текст на оразмеряване (празно = измерена стойност): + .dimaligned-select-object-to-dimension = DIMALIGNED Избери обект за оразмеряване: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Посочи местоположение на линията за оразмеряване [Mtext/Текст/Ъгъл]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED Посочи начало на първата удължителна линия или натисни Enter за избор на обект: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED Посочи начало на втората удължителна линия: .dimaligned-specify-text-angle-degrees = DIMALIGNED Посочи ъгъл на текста (градуси): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR Въведи текст на оразмеряване (празно = измерена стойност): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR Въведи форматиран текст на оразмеряване (празно = измерена стойност): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR Избери дъга, окръжност, линия или посочи връх на ъгъла: + .dimangular-select-second-line = DIMANGULAR Избери втората линия: .dimangular-specify-angle-vertex = DIMANGULAR Посочи връх на ъгъла: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR Посочи местоположение на дъгата за оразмеряване [Mtext/Текст/Ъгъл/Квадрант]: .dimangular-specify-dimension-arc-location = DIMANGULAR Посочи местоположение на дъгата за оразмеряване: .dimangular-specify-first-extension-line-point = DIMANGULAR Посочи точка на първата удължителна линия: + .dimangular-specify-quadrant = DIMANGULAR Посочи квадрант: .dimangular-specify-second-extension-line-point = DIMANGULAR Посочи точка на втората удължителна линия: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR Посочи втора точка за ъгъла на текста: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR Посочи ъгъл на текста или първа точка: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE Не е намерено базово оразмеряване. Първо постави оразмеряване. .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE Посочи начало на втора удължителна линия (Enter за изход): .dimbreak-select-dimension-to-break = DIMBREAK Избери оразмеряване за прекъсване: @@ -1813,8 +1824,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE Не е намерено базово оразмеряване. Първо постави оразмеряване. .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE Посочи начало на втора удължителна линия (Enter за изход): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER Въведи текст на оразмеряване (празно = измерена стойност): - .dimdiameter-specify-center-point = DIMDIAMETER Посочи централна точка: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER Посочи местоположение на линията за оразмеряване [Текст/Ъгъл]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER Избери дъга, окръжност или дъгов сегмент на полилиния: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER Посочи местоположение на линията за оразмеряване [Мтекст/Текст/Ъгъл]: .dimdiameter-specify-point-on-circle = DIMDIAMETER Посочи точка на окръжност: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER Посочи ъгъл на текста (градуси): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT Въведи заместващ текст (празно = върни към измерена стойност): @@ -1830,10 +1841,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR Посочи начало на втората удължителна линия [Текст/Ъгъл]: .dimlinear-specify-text-angle-degrees = DIMLINEAR Посочи ъгъл на текста (градуси): .dimordinate-specify-feature-location = DIMORDINATE Посочи местоположение на елемента: - .dimordinate-specify-leader-endpoint = DIMORDINATE Посочи крайна точка на водача: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE Въведи текст на оразмеряване (празно = измерена стойност): + .dimordinate-specify-leader-endpoint = DIMORDINATE Посочи крайна точка на водача [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE Посочи ъгъл на текста (градуси): .dimradius-enter-dimension-text-blank-measured = DIMRADIUS Въведи текст на оразмеряване (празно = измерена стойност): - .dimradius-specify-center-point = DIMRADIUS Посочи централна точка: - .dimradius-specify-dimension-line-location-text = DIMRADIUS Посочи местоположение на линията за оразмеряване [Текст/Ъгъл]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Избери дъга, окръжност или дъгов сегмент на полилиния: + .dimradius-specify-dimension-line-location-text = DIMRADIUS Посочи местоположение на линията за оразмеряване [Мтекст/Текст/Ъгъл]: .dimradius-specify-radius-point = DIMRADIUS Посочи точка на радиус: .dimradius-specify-text-angle-degrees = DIMRADIUS Посочи ъгъл на текста (градуси): .dimspace-enter-value-0-auto = DIMSPACE Въведи стойност (0 = авто): @@ -1865,6 +1878,7 @@ annotate = данни .lower = Малки букви .mleader-specify-arrowhead-point = MLEADER Посочи точка на върха на стрелката: + .mleader-specify-landing-point = MLEADER Посочи точката на хоризонталния участък: .mleader-specify-next-point-count-pts-enter-to = MLEADER Посочи следваща точка [__ocs_arg_count__ т. — Enter за поставяне на текст]: .mleaderadd-select-a-multileader = MLEADERADD Избери многоводач: .mleaderadd-specify-arrowhead-location = MLEADERADD Посочи местоположение на върха на стрелката: @@ -2034,6 +2048,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ обект(а) изрязани в клипборда. model = + .create = Създаване + .boolean = Булеви операции + .pyramid = Пирамида + .polysolid = Полисолид + .extrude = Екструдиране + .revolve = Завъртане + .loft = Лофт + .sweep = Изтегляне по траектория + .presspull = Натискане/издърпване .n-specify-center-point = __ocs_arg_n__ Посочи централна точка: .n-specify-first-corner = __ocs_arg_n__ Посочи първи ъгъл: .n-specify-height-enter-for-default = __ocs_arg_n__ Посочи височина : @@ -2334,6 +2357,7 @@ view = .zoom-extents = Мащаб Обхват + .cmd-no-hatch-objects = __ocs_fmt_0__: ð¢ÐÅð╝ð░ ÐëÐÇð©Ðàð¥ð▓ÐèÐçð¢ð© ð¥ð▒ðÁð║Ðéð©. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: няма текстови обекти или оразмерявания. .cmd-not-yet-implemented = __ocs_fmt_0__: все още не е реализирано. .viewport-s-in-layout = __ocs_fmt_0__ изглед(а) в оформление "__ocs_fmt_1__": @@ -2712,6 +2736,9 @@ properties = .plot-style-table = Таблица със стилове за плотиране .plot-table-attached-to = Таблицата за плотиране е прикачена към .plot-table-type = Тип на таблица за плотиране + .color-dependent-plot-styles = Цветово-зависими стилове за печат + .named-plot-styles = Именувани стилове за печат + .plot-style-is-locked-to-color-in-color-dependent = Стилът за печат е заключен към цвета в цветово-зависим режим (CTB) .point-1-x = Точка 1 X .point-1-y = Точка 1 Y .point-1-z = Точка 1 Z diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 7c2337f8..92cf2e7d 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Kreslení .annotate = Poznámky .insert = Vložit - .model = Model + .model = Modelování .layout = Rozvržení .manage = Správa .view = Zobrazení @@ -332,6 +332,7 @@ common = .brightness = Jas .by-layer = Podle vrstvy .byblock = ByBlock + .bycolor = Podle barvy .bylayer = Podle vrstvy .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: převede tělesa __ocs_fmt_0__ na povrchy. .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: vyberte těleso vytvořené v této relaci. @@ -1798,14 +1799,24 @@ annotate = .collect-leaders = Sbírejte vůdce .ddedit-select-text-entity = DDEDIT Vyberte textovou entitu: .dimaligned-enter-dimension-text-blank-measured = Text rozměru DIMALIGNED Enter (prázdná naměřená hodnota =): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Určete umístění kótovací čáry [Text/Úhel]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED Zadejte počátek prvního vynášecího řádku: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED Zadejte počátek druhé vynášecí čáry [Text/Úhel]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED Zadejte formátovaný text kóty (prázdné = naměřená hodnota): + .dimaligned-select-object-to-dimension = DIMALIGNED Vyberte objekt ke kótování: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Určete umístění kótovací čáry [Mtext/Text/Úhel]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED Zadejte počátek první vynášecí čáry nebo stiskněte Enter pro výběr objektu: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED Zadejte počátek druhé vynášecí čáry: .dimaligned-specify-text-angle-degrees = DIMALIGNED Zadejte úhel textu (stupně): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR Zadejte text kóty (prázdné = naměřená hodnota): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR Zadejte formátovaný text kóty (prázdné = naměřená hodnota): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR Vyberte oblouk, kružnici či čáru, nebo zadejte vrchol úhlu: + .dimangular-select-second-line = DIMANGULAR Vyberte druhou čáru: .dimangular-specify-angle-vertex = DIMANGULAR Zadejte vrchol úhlu: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR Zadejte umístění oblouku kóty [Mtext/Text/Úhel/Kvadrant]: .dimangular-specify-dimension-arc-location = DIMANGULAR Zadejte umístění oblouku kóty: .dimangular-specify-first-extension-line-point = DIMANGULAR Zadejte první bod vynášecí čáry: + .dimangular-specify-quadrant = DIMANGULAR Zadejte kvadrant: .dimangular-specify-second-extension-line-point = DIMANGULAR Zadejte druhý bod vynášecí čáry: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR Zadejte druhý bod úhlu textu: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR Zadejte úhel textu nebo první bod: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE Nebyl nalezen žádný rozměr základny. Nejprve umístěte kótu. .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE Zadejte počátek druhé prodlužovací čáry (Enter pro ukončení): .dimbreak-select-dimension-to-break = DIMBREAK Vyberte rozměr, který chcete přerušit: @@ -1813,8 +1824,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE Nebyl nalezen žádný rozměr základny. Nejprve umístěte kótu. .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE Zadejte počátek druhé prodlužovací čáry (Enter pro ukončení): .dimdiameter-enter-dimension-text-blank-measure = Text rozměru DIMDIAMETER Enter (prázdná naměřená hodnota =): - .dimdiameter-specify-center-point = DIMDIAMETER Zadejte středový bod: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER Určete umístění kótovací čáry [Text/Úhel]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER Vyberte oblouk, kružnici nebo obloukový segment křivky: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER Určete umístění kótovací čáry [MText/Text/Úhel]: .dimdiameter-specify-point-on-circle = DIMDIAMETER Určete bod na kružnici: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER Zadejte úhel textu (stupně): .dimedit-enter-text-override-blank-reset-to-mea = Přepsání textu DIMEDIT Enter (prázdné = resetováno na měření): @@ -1830,10 +1841,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR Zadejte počátek druhé vynášecí čáry [Text/Úhel]: .dimlinear-specify-text-angle-degrees = DIMLINEAR Zadejte úhel textu (stupně): .dimordinate-specify-feature-location = DIMORDINATE Zadejte umístění funkce: - .dimordinate-specify-leader-endpoint = DIMORDINATE Zadejte koncový bod odkazu: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE Zadejte text rozměru (prázdné = naměřená hodnota): + .dimordinate-specify-leader-endpoint = DIMORDINATE Zadejte koncový bod odkazu [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE Zadejte úhel textu (stupně): .dimradius-enter-dimension-text-blank-measured = Text rozměru DIMRADIUS Enter (prázdná naměřená hodnota =): - .dimradius-specify-center-point = DIMRADIUS Zadejte středový bod: - .dimradius-specify-dimension-line-location-text = DIMRADIUS Určete umístění kótovací čáry [Text/Úhel]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Vyberte oblouk, kružnici nebo obloukový segment křivky: + .dimradius-specify-dimension-line-location-text = DIMRADIUS Určete umístění kótovací čáry [MText/Text/Úhel]: .dimradius-specify-radius-point = DIMRADIUS Zadejte bod poloměru: .dimradius-specify-text-angle-degrees = DIMRADIUS Zadejte úhel textu (stupně): .dimspace-enter-value-0-auto = Hodnota DIMSPACE Enter (0 = auto): @@ -1865,6 +1878,7 @@ annotate = Data .lower = Nižší .mleader-specify-arrowhead-point = MLEADER Zadejte bod šipky: + .mleader-specify-landing-point = MLEADER Zadejte bod odskoku: .mleader-specify-next-point-count-pts-enter-to = MLEADER Zadejte další bod [__ocs_arg_count__ bodů — Enter pro umístění textu]: .mleaderadd-select-a-multileader = MLEADERADD Vyberte multiodkaz: .mleaderadd-specify-arrowhead-location =MLEADERADD Zadejte umístění šipky: @@ -2034,6 +2048,15 @@ insert = .object-s-cut-to-clipboard = Objekt(y) __ocs_fmt_0__ vyříznutý do schránky. model = + .create = Vytvořit + .boolean = Booleovské operace + .pyramid = Jehlan + .polysolid = Polysolid + .extrude = Vysunutí + .revolve = Rotace + .loft = Přechod + .sweep = Tažení + .presspull = Tlačit/táhnout .n-specify-center-point = __ocs_arg_n__ Zadejte středový bod: .n-specify-first-corner = __ocs_arg_n__ Určete první roh: .n-specify-height-enter-for-default = __ocs_arg_n__ Zadejte výšku : @@ -2334,6 +2357,7 @@ view = .zoom-extents = Přiblížení Rozsahy + .cmd-no-hatch-objects = __ocs_fmt_0__: ┼¥├ídn├® ┼írafovac├¡ objekty. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: žádný text ani kótovací objekty. .cmd-not-yet-implemented = __ocs_fmt_0__: dosud neimplementováno. .viewport-s-in-layout =Výřezy __ocs_fmt_0__ v rozložení "__ocs_fmt_1__": @@ -2711,6 +2735,9 @@ properties = .plot-style-table = Tabulka stylu pozemku .plot-table-attached-to =Plot tabulka připojena k .plot-table-type = Typ tabulky grafu + .color-dependent-plot-styles = Barevně závislé styly vykreslování + .named-plot-styles = Pojmenované styly vykreslování + .plot-style-is-locked-to-color-in-color-dependent = V barevně závislém režimu (CTB) je styl vykreslování uzamčen podle barvy .point-1-x = Bod 1 X .point-1-y = Bod 1 Y .point-1-z = Bod 1 Z diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index c17b0730..e71535fe 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -29,7 +29,7 @@ action = ribbon-tab = .draw = Zeichnen .annotate = Beschriften - .insert = Einsatz + .insert = Einfügen .model = Modell .layout = Layout .manage = Verwalten @@ -53,7 +53,7 @@ options = .browser-storage = Browserspeicherung .dark = Dunkel .default-application = Standardanwendung - .iced-theme = Eisthema: + .iced-theme = Iced-Motiv: .theme = Motiv command-line = @@ -68,9 +68,9 @@ start = .new-drawing = Neue Zeichnung .open-file = Datei öffnen… .donate = Spenden - .send-feedback = Feedback senden + .send-feedback = Rückmeldung senden .sponsors = Sponsoren - .tutorials = Lernprogramme + .tutorials = Lerneinheiten .loading-videos = Videos werden geladen… .videos-online = Videos werden aus dem Internet geladen. .open-playlist = Wiedergabeliste auf YouTube öffnen @@ -94,21 +94,21 @@ modal = .keyboard-shortcuts = Tastenkombinationen .command-aliases = Befehlsaliase .find-replace = Suchen und Ersetzen - .plugin-manager = Plug-in-Manager + .plugin-manager = Plug-in-Verwaltung .update-available = Aktualisierung verfügbar - .layer-manager = Layer-Manager - .layer-state-manager = Layerstatus-Manager + .layer-manager = Layer-Verwaltung + .layer-state-manager = Layerstatus-Verwaltung .edit-layer-state = Layerstatus bearbeiten .plot = Plotten - .layout-manager = Layout-Manager - .scale-manager = Maßstabs-Manager + .layout-manager = Layoutverwaltung + .scale-manager = Maßstabsverwaltung .annotation-object-scale = Maßstab des Beschriftungsobjekts .plot-style-editor = Plotstileditor - .text-style-manager = Textstil-Manager - .multiline-style-manager = Mehrfachlinienstil-Manager - .table-style-manager = Tabellenstil-Manager - .multileader-style-manager = Multi-Führungslinienstil-Manager - .dimension-style-manager = Bemaßungsstil-Manager + .text-style-manager = Textstil-Verwaltung + .multiline-style-manager = Mehrfachlinienstil-Verwaltung + .table-style-manager = Tabellenstil-Verwaltung + .multileader-style-manager = Multi-Führungslinienstil-Verwaltung + .dimension-style-manager = Bemaßungsstil-Verwaltung .default-application = Standardanwendung .save-warning = Speicherwarnung .unable-save = Zeichnung kann nicht gespeichert werden @@ -136,13 +136,13 @@ modal = .not-now = Nicht jetzt .open-dwg-and-dxf-drawings-in-open-cad-studio-b = Öffnen Sie .dwg und .dxf Zeichnungen in Open CAD Studio standardmäßig. .path-path = Pfad: __ocs_arg_path__ - .reload-from-disk = Reload von Disk - .save-as = Sparen Sie sich als... - .saving-now-could-destroy-those-external-change = Speichern Sie jetzt diese externen Änderungen zerstören könnte, laden Sie die Festplattenkopie, speichern Sie Ihre lokale Arbeit an anderer Stelle, oder explizit überschreiben. - .the-file-is-open-or-being-used-by-another-appl = Die Datei ist geöffnet oder wird von einer anderen Anwendung verwendet. Schließen Sie sie dort und versuchen Sie es erneut oder speichern Sie diese Zeichnung unter einem anderen Namen. + .reload-from-disk = Von Festplatte neu laden + .save-as = Speichern unter... + .saving-now-could-destroy-those-external-change = Wenn Sie jetzt speichern, könnten diese externen Änderungen verloren gehen. Laden Sie die Festplattenkopie neu, speichern Sie Ihre lokale Arbeit an anderer Stelle oder überschreiben Sie sie ausdrücklich. + .the-file-is-open-or-being-used-by-another-appl = Die Datei ist geöffnet oder wird von einer anderen Anwendung verwendet. Schließen Sie die andere Anwendung und versuchen Sie es erneut oder speichern Sie diese Zeichnung unter einem anderen Namen. .the-selected-layer-state-is-no-longer-availabl = Der ausgewählte Ebenenzustand steht nicht mehr zur Verfügung. .this-drawing-contains-count-aec-civil-objects = - Diese Zeichnung enthält __ocs_arg_count__ AEC/Zivilobjekte, die "__ocs_arg_target__" nicht speichern können, so dass sie nicht gespeichert werden. + Diese Zeichnung enthält __ocs_arg_count__ AEC/Civilobjekte, die "__ocs_arg_target__" nicht speichern können, so dass sie nicht gespeichert werden. Um sie zu behalten, speichern Sie in der Quellversion (__ocs_arg_src_version__). .yes-set-as-default = Ja, Standardwert .has = enthält @@ -157,13 +157,13 @@ clipboard = .occupied = Die Systemzwischenablage wird gerade verwendet. Versuchen Sie es erneut. .conversion-failed = Der Text in der Zwischenablage konnte nicht gelesen werden. .copybase-specify-base-point = COPYBASE Basispunkt angeben: - .copy-to-clipboard = Kopie an Clipboard + .copy-to-clipboard = Kopie in die Zwischenablage .cut = Ausschneiden - .cut-to-clipboard = Zugeschnitten auf Clipboard - .pasteclip-pick-insertion-point = PASTECLIP Einsteckpunkt: - .paste-as-block = Paste als Block - .paste-from-clipboard = Paste aus Clipboard - .paste-to-original-coordinates = Paste zu Originalkoordinaten + .cut-to-clipboard = Ausschnitt in die Zwischenablage + .pasteclip-pick-insertion-point = PASTECLIP Einfügepunkt: + .paste-as-block = Einfügen als Block + .paste-from-clipboard = Einfügen aus der Zwischenablage + .paste-to-original-coordinates = Einfügen auf Originalkoordinaten viewport = .split-vertical = Ansichtsfenster vertikal teilen @@ -189,7 +189,7 @@ recovery = .show-log = Wiederherstellungsprotokoll anzeigen .save-new-file-required = Eine reparierte Zeichnung muss als neue Datei gespeichert werden. .prompt-heading = Normales Öffnen ist fehlgeschlagen - .prompt-description = Die Zeichnung wurde nicht verändert. Der Wiederherstellungsmodus kann versuchen, verwendbare Daten zu retten; beschädigte Datensätze können dabei übersprungen werden. Möchten Sie fortfahren? + .prompt-description = Die Zeichnung wurde nicht verändert. Der Wiederherstellungsmodus kann versuchen verwendbare Daten zu retten; beschädigte Datensätze können dabei übersprungen werden. Möchten Sie fortfahren? .attempt = Wiederherstellung versuchen .decline = Abbrechen @@ -256,14 +256,14 @@ common = .value-2nd-arrowhead-dimblk2 = 2. Pfeilspitze (DIMBLK2) .value-3d-wireframe = 3D-Drahtmodell .value-3dalign-aligned-the-solid-to-the-destination-p = 3DALIGN: Ausrichtung des Festkörpers auf die Zielpunkte. - .value-3dalign-each-point-triple-must-be-non-coincide = 3DALIGN: Jeder Punkt dreifach muss nicht zusammenfallend und nicht kolinear sein. - .value-3dalign-select-exactly-one-solid-created-this = 3DALIGN: Wählen Sie genau einen Solid, der diese Sitzung erstellt hat. - .value-3dmirror-added-a-mirror-across-the-plane = 3DMIRROR: Ein Spiegel über der Ebene __ocs_fmt_0__ wurde hinzugefügt. - .value-3dmirror-could-not-determine-the-solid-s-exten = 3DMIRROR: konnte die Ausdehnung des Feststoffs nicht bestimmen. - .value-3dmirror-select-exactly-one-solid-created-this = 3DMIRROR: Wählen Sie genau einen Solid, der diese Sitzung erstellt hat. - .value-3drotate-could-not-determine-the-solid-s-exten = 3DROTATE: konnte die Ausdehnung des Feststoffs nicht bestimmen. + .value-3dalign-each-point-triple-must-be-non-coincide = 3DALIGN: Jedes Punkte-Triple muss nicht zusammenfallend und nicht kolinear sein. + .value-3dalign-select-exactly-one-solid-created-this = 3DALIGN: Wählen Sie genau einen erstellten Volumenkörper. + .value-3dmirror-added-a-mirror-across-the-plane = 3DMIRROR: Eine Spiegelung über der Ebene __ocs_fmt_0__ wurde hinzugefügt. + .value-3dmirror-could-not-determine-the-solid-s-exten = 3DMIRROR: konnte die Ausdehnung des Volumenkörpers nicht bestimmen. + .value-3dmirror-select-exactly-one-solid-created-this = 3DMIRROR: Wählen Sie genau einen erstellten Volumenkörper. + .value-3drotate-could-not-determine-the-solid-s-exten = 3DROTATE: konnte die Ausdehnung des Volumenkörpers nicht bestimmen. .value-3drotate-rotated-angle-deg-about-the-axis = 3DROTATE: gedreht __ocs_fmt_0__° um die Achse __ocs_fmt_1__. - .value-3drotate-select-exactly-one-solid-created-this = 3DROTATE: Wählen Sie genau einen Solid, der diese Sitzung erstellt hat. + .value-3drotate-select-exactly-one-solid-created-this = 3DROTATE: Wählen Sie genau einen erstellten Volumenkörper. .align-applied = ALIGN: angewendet. .above = Über .accept = Akzeptieren @@ -274,26 +274,26 @@ common = .alignment-title-case = Ausrichtung .all = Alle .alternate = Alternativ - .alternate-units = Wechseleinheiten + .alternate-units = Alternative Einheiten .angle = Winkel .angular-decimals-dimadec = Winkeldezimalstellen (DIMADEC) .angular-unit-dimaunit = Winkeleinheit (DIMAUNIT) .angular-zero-supp-dimazin = Winkelnullpunkt supp (DIMAZIN) .annotation = Anmerkung - .annotative = Anmerkung + .annotative = Erläuternd .apparent-intersection = Sichtbarer Schnitt .apply = Beantragen .apply-cell = Anwendungszelle - .apply-margins = Anwendung von Einschusszahlungen + .apply-margins = Ränder anwenden .arc = Bogen .arch = Bogen - .architectural = Architektur + .architectural = Architektonisch .area = Fläche .arrow-size = Pfeilgröße .arrowhead = Pfeilkopf .arrowhead-size = Größe des Pfeilkopfes .as-displayed = Wie dargestellt - .attachment-direction = Anlagerichtung: + .attachment-direction = Richtung der Anlage: .attribute = Attribut .attributes = Attribute .auto = auto @@ -315,62 +315,63 @@ common = .blocks = Blöcke .blue = Blau .bulge = Wölbung - .boolean-failed-the-solids-may-not-overlap = Boolean versagte - die Feststoffe überlappen sich möglicherweise nicht. - .boolean-select-exactly-two-solids-created-this = Boolean: Wählen Sie genau zwei Feststoffe erstellt diese Sitzung. + .boolean-failed-the-solids-may-not-overlap = Boolean versagte - die Volumenkörper überlappen sich möglicherweise nicht. + .boolean-select-exactly-two-solids-created-this = Boolean: Wählen Sie genau zwei erstellte Volumenkörper. .bottom = Unten .bottom-center-title-case = Unten zentriert .bottom-left-title-case = Unten links .bottom-right-title-case = Unten rechts - .bottom-attachment = Bodenbefestigung: - .bottom-center-sentence-case = untere Mitte + .bottom-attachment = Befestigung unten: + .bottom-center-sentence-case = Unten mittig .bottom-left-sentence-case = Unten links - .bottom-right-sentence-case = Rechts unten rechts - .bottomcenter = untere Mitte + .bottom-right-sentence-case = Unten rechts + .bottomcenter = Untere Mitte .bottomleft = Unten links - .bottomright = Rechts unten rechts + .bottomright = Unten rechts .box = Quader .break = Bruch .brightness = Helligkeit .by-layer = Nach Lage .byblock = Block + .bycolor = Nach Farbe .bylayer = ByLayer .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: Umgerechnet __ocs_fmt_0__ Feststoff(e) in Oberfläche(n). - .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: Wählen Sie einen Solid, der diese Sitzung erstellt hat. + .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: Wählen Sie einen erstellten Volumenkörper. .coords-mode-label = COORDS = __ocs_fmt_0__ (__ocs_fmt_1__) .copy-specify-destination-count-copies-so-far-a = COPY Ziel angeben [bisher __ocs_arg_count__ Kopien | Reihe | Eingabe=Fertig | Basis __ocs_arg_bx__,__ocs_arg_by__]: .cancel = Abbrechen - .cancel-active-command = Aktives Kommando abbrechen - .cannot-delete-a-style-that-is-current-or-in-us = Kann einen Stil, der aktuell oder in Gebrauch ist, nicht löschen. - .cannot-delete-the-model-layout = Das Layout des Modells kann nicht gelöscht werden. + .cancel-active-command = Aktiven Befehl abbrechen + .cannot-delete-a-style-that-is-current-or-in-us = Der Stil ist aktuell in Verwendung und kann nicht gelöscht werden. + .cannot-delete-the-model-layout = Das Modell-Layout kann nicht gelöscht werden. .cannot-delete-the-standard-style = Der Standardstil kann nicht gelöscht werden. - .cannot-rename-the-model-layout = Kann das Layout des Modells nicht umbenennen. - .cannot-rename-the-standard-style = Kann den Standard-Stil nicht umbenennen. + .cannot-rename-the-model-layout = Das Modell-Layout kann nicht umbenannt werden. + .cannot-rename-the-standard-style = Der Standard-Stil kann nicht umbenannt werden. .catchment-tagged-successfully = Catchment erfolgreich verschlagwortet. .center = Mitte .center-mark-dimcen = Mittelmarke (DIMCEN) .centered = Zentriert .centimeters = Zentimeter - .chamfer = Scheide + .chamfer = Fase .changelog = Änderungsprotokoll - .changing-a-base-colour-switches-to-custom-iced = Durch Wechseln einer Grundfarbe wird auf Custom umgestellt. Iced erzeugt aus diesen sechs Farben jeden Farbton. + .changing-a-base-colour-switches-to-custom-iced = Durch Wechseln einer Grundfarbe wird auf benutzerdefiniert umgestellt. Iced erzeugt aus diesen sechs Farben jeden Farbton. .circle = Kreis .class-version = Klassenversion .clear = Klar - .clear-all = Alles klar + .clear-all = Alles löschen .clip = Zuschneiden .clipboard = Zwischenablage .clipboard-is-empty = Die Zwischenablage ist leer. .close = Schließen .closed-filled = Geschlossen gefüllt .color = Farbe - .command-cancelled-because-the-active-drawing-s = Befehl abgesagt, weil sich der aktive Zeichenraum geändert hat. - .command-history-navigation = Durch den Befehlsverlauf navigieren - .command-label-title-case = Kommando: + .command-cancelled-because-the-active-drawing-s = Befehl abgebrochen, weil sich der aktive Zeichenraum geändert hat. + .command-history-navigation = Navigation im Befehlsverlauf + .command-label-title-case = Befehl: .conceptual = Konzeptionell - .cone = Zapfen + .cone = Kegel .content = Inhalt .content-type = Inhaltsart: - .continue = Weiterfahren + .continue = Fortsetzen .contrast = Kontrast .coordinates = Koordinaten .copied-path = Kopierter Pfad: __ocs_fmt_0__ @@ -387,7 +388,7 @@ common = .cyan = Cyan .cylinder = Zylinder .dataextraction-rows-rows-fname = DATAEXTRACTION __ocs_fmt_0__ rows → "__ocs_fmt_1__" - .dataextraction-write-failed-e = DATAEXTRACTION: Schreibe fehlgeschlagen: __ocs_fmt_0__ + .dataextraction-write-failed-e = DATAEXTRACTION: Schreiben fehlgeschlagen: __ocs_fmt_0__ .ddedit-entity-type-not-supported = DDEDIT: Objekttyp wird nicht unterstützt. .ddedit-text-updated = DDEDIT: Text aktualisiert. .dimbreak-not-yet-implemented-nothing-changed = DIMBREAK: noch nicht umgesetzt – nichts hat sich geändert. @@ -406,7 +407,7 @@ common = .decimals-dimdec = Dezimalstellen (DIMDEC) .default-title-case = Standard .default-text = Standardtext: - .deg-min-sec = Grad/Min/sec + .deg-min-sec = Grad/Min/Sek .delete = Löschen .delete-selected-entities = Ausgewählte Objekte löschen .description-title-case = Beschreibung @@ -415,23 +416,23 @@ common = .dim-style = Bemaßungsstil .dimension = Bemaßung .dimensions = Bemaßungen - .discard = Rückwurf + .discard = Verwerfen .discussions = Diskussionen - .discussions-load-from-github = Diskussionslast von GitHub. + .discussions-load-from-github = Diskussionen von GitHub laden. .display = Anzeige - .distance = Distanz + .distance = Entfernung .donate = Spenden .done = Fertig .double = Doppelt .draft = Entwurf .draw = Zeichnen .drawing = Zeichnung - .drawing-units = Zeichenwerke - .drop-0-ft-keep-0-in = Unterdrücken Sie Null Fuß, halten Sie Null Zoll + .drawing-units = Zeichnungseinheiten + .drop-0-ft-keep-0-in = Unterdrücken Sie Null Fuß, behalten Sie Null Zoll .dynamic = Dynamik .extrude-could-not-build-profile-select-a-close = EXTRUDE: konnte kein Profil erstellen. Wählen Sie eine geschlossene 2D-Entität (Circle, LwPolyline, etc.). .extrude-entity-not-found = EXTRUDE: Objekt nicht gefunden. - .extrude-solid-created = EXTRUDE: solide erstellt. + .extrude-solid-created = EXTRUDE: Volumenkörper erstellt. .edge = Rand .editing-selected = Bearbeiten: __ocs_arg_selected__ .elements = Elemente: @@ -442,37 +443,37 @@ common = .engineering = Technisch .enter = Eingabetaste .expected-coordinates-x-y-or-a-number-got-text = Erwartete Koordinaten (x,y) oder eine Zahl, erhalten: "__ocs_fmt_0__" - .extension = Verlängerung + .extension = Erweiterung .extension-dimdle = Erweiterung (DIMDLE) .extension-dimexe = Erweiterung (DIMEXE) - .extents = Umfang + .extents = Ausmaße .find-replace-replaced-1-occurrence-of-search = FIND/REPLACE: ersetzt 1 Vorkommen von "__ocs_fmt_0__". .find-replace-replaced-replaced-occurrence-s-of = FIND/REPLACE: ersetzt durch __ocs_fmt_0__ Vorkommen von "__ocs_fmt_1__". .flatshot-created-n-projected-edge-s-at-z-0 = FLATSHOT: erstellt __ocs_fmt_0__ projizierte Kante(n) bei Z=0. - .flatshot-select-a-solid-created-this-session = FLATSHOT: Wählen Sie einen Solid, der diese Sitzung erstellt hat. - .fade = Ausblendung + .flatshot-select-a-solid-created-this-session = FLATSHOT: Wählen Sie einen erstellten Volumenkörper. + .fade = Abblendung .failed-to-create-layout-e = Fehler beim Erstellen des Layouts: __ocs_fmt_0__ - .feet = Füße + .feet = Fuß .fence = Zaun .file-locations-are-unavailable-in-the-web-appl = Dateispeicherorte sind in der Webanwendung nicht verfügbar. .fill-color-sentence-case = Füllfarbe .fill-color-aci-dimtfillclr = Farbe ausfüllen ACI (DIMTFILLCLR) - .fillet = Filet - .finalize-command-repeat-last = Befehl finalisieren / Last Repeat - .finalizing = Finalisierung... - .find-and-replace = Finden und Ersetzen - .first-seg-angle = erster Nähwinkel: - .fit = Anpassen + .fillet = Hohlkehle + .finalize-command-repeat-last = Befehl abschließen / Letzten wiederholen + .finalizing = Fertigstellung... + .find-and-replace = Suchen und Ersetzen + .first-seg-angle = erster Segmentwinkel: + .fit = Einpassen .fit-dimatfit = Einpassen (DIMATFIT) .fit-legacy-dimfit = Einpassen (veraltet: DIMFIT) .fixed-height = Feste Höhe: .fixed-length-dimfxl = Feste Länge (DIMFXL) .fixed-length-ext-lines-dimfxlon = Länge der Ext-Linien (DIMFXLON) - .flat-shaded = Flach beschattet + .flat-shaded = Flach schattiert .flat-shaded-edges = Flache schattiert + Kanten - .flow-direction = Strömungsrichtung: - .font-load-failed-script-e = Schriftladefehler (__ocs_fmt_0__): __ocs_fmt_1__ - .force-text-inside-dimtix = Text drinnen erzwingen (DIMTIX) + .flow-direction = Fließrichtung: + .font-load-failed-script-e = Ladefehler Schrift (__ocs_fmt_0__): __ocs_fmt_1__ + .force-text-inside-dimtix = Text innen erzwingen (DIMTIX) .format = Format .format-dimlunit = Format (DIMLUNIT) .fraction-format-dimfrac = Format der Fraktion (DIMFRAC) @@ -501,7 +502,7 @@ common = .height-dimtxt = Höhe (DIMTXT) .helix = Helix .hidden = Verdeckt - .hidden-line = Versteckte Linie + .hidden-line = Verdeckte Linie .high = hoch .horizontal = Horizontal .horizontal-inside-dimtih = Horizontal innen (DIMTIH) @@ -513,25 +514,25 @@ common = .importobj-imported-file-stem-as-mesh = IMPORTOBJ: importiertes "__ocs_fmt_0__" als mesh. .importobj-target-drawing-was-closed = IMPORTOBJ: Zielzeichnung wurde geschlossen. .importobj-error = IMPORTOBJ: __ocs_fmt_0__ - .interfere-created-an-interference-solid-from-t = INTERFERE: erzeugte einen Interferenzkörper aus der Überlappung. - .interfere-select-exactly-two-solids-created-th = INTERFERE: Wählen Sie genau zwei Festkörper aus, die diese Sitzung erstellt haben. - .interfere-the-selected-solids-do-not-overlap = INTERFERE: die ausgewählten Feststoffe überlappen sich nicht. - .import = Einfuhr + .interfere-created-an-interference-solid-from-t = INTERFERE: erzeugte einen Schnittkörper aus der Überlappung. + .interfere-select-exactly-two-solids-created-th = INTERFERE: Wählen Sie genau zwei erstellte Volumenkörper aus. + .interfere-the-selected-solids-do-not-overlap = INTERFERE: die ausgewählten Volumenkörper überlappen sich nicht. + .import = Import .inches = Zoll .index = Index - .insert = Einsatz + .insert = Einfügen .insert-block = Block einfügen - .insertion = Einsetzen + .insertion = Einfügung .installed = Installiert .intersection = Schnittpunkt .invert-selection-object-s-selected = Invertierte Auswahl: __ocs_fmt_0__ Objekt(e) ausgewählt. .invisible = Unsichtbar .jis = JIS .join-count-in-object-s-joined-into-count-out = JOIN: __ocs_fmt_0__ Objekt(e), das zu __ocs_fmt_1__ verbunden ist. - .jog-angle-dimjogang = Jogwinkel ° (DIMJOGANG) - .join = Beitritt + .jog-angle-dimjogang = Schrägwinkel ° (DIMJOGANG) + .join = Verbindung .keep-0-ft-drop-0-in = Halten Sie Null Fuß, unterdrücken Sie Null Zoll - .keep-recent-files = Bewahren Sie aktuelle Dateien auf + .keep-recent-files = Erhalten Sie aktuelle Dateien .kilometers = Kilometer .layerstate-deleted-name = LAYERSTATE: gelöscht "__ocs_fmt_0__". .layerstate-restored-name-restored-layer-s = LAYERSTATE: wiederhergestellt "__ocs_fmt_0__" (__ocs_fmt_1__ Layer(s)). @@ -540,8 +541,8 @@ common = .layout-e = LAYOUT: __ocs_fmt_0__ .lengthen-applied = LENGTHEN: angewendet. .lengthen-entity-type-not-supported = LENGTHEN: Objekttyp wird nicht unterstützt. - .loft-could-not-loft-profiles-ensure-sections-h = LOFT: konnte keine Profile hochladen. Stellen Sie sicher, dass Abschnitte die gleiche Kantenzahl haben und kompatibel sind. - .loft-solid-created-from-profiles = LOFT: Solid erstellt aus __ocs_fmt_0__ Profilen. + .loft-could-not-loft-profiles-ensure-sections-h = LOFT: konnte keine Profile hochladen. Stellen Sie sicher, dass Schnitte die gleiche Kantenzahl haben und kompatibel sind. + .loft-solid-created-from-profiles = LOFT: Volumenkörper aus __ocs_fmt_0__ Profilen erstellt. .landing-distance-sentence-case = Anlandestrecke .landing-distance-label-sentence-case = Landestrecke: .landing-gap-sentence-case = Landelücke @@ -550,25 +551,25 @@ common = .language-title-case = Sprache .language-label-title-case = Sprache: .layer = Ebene - .layer-matched-to-layer = Layer mit "__ocs_fmt_0__" abgestimmt. + .layer-matched-to-layer = Ebene mit "__ocs_fmt_0__" abgestimmt. .layer-state-name-already-exists = Der Ebenenzustand "__ocs_fmt_0__" existiert bereits. .layer-state-name-was-not-found = Ebenenzustand "__ocs_fmt_0__" wurde nicht gefunden. - .layer-state-name-cannot-be-empty = Der Name des Layer-Status kann nicht leer sein. - .layers = Layer + .layer-state-name-cannot-be-empty = Der Name des Ebenen-Status kann nicht leer sein. + .layers = Ebenen .layout = Layout .layout-name-silindi = Layout „__ocs_fmt_0__“ gelöscht .layout-new-name-created-use-mview-to-add-a-vie = Layout „__ocs_fmt_0__“ erstellt — mit MVIEW kann ein Ansichtsfenster hinzugefügt werden - .layout-orig-new-name = Layout__ocs_fmt_0__" → "__ocs_fmt_1__" - .layout-name-created = Layout '__ocs_fmt_0__' erstellt. - .layout-name-deleted = Layout '__ocs_fmt_0__' gelöscht. - .layout-manager = Layout-Manager + .layout-orig-new-name = Layout „__ocs_fmt_0__“ → „__ocs_fmt_1__“ + .layout-name-created = Layout „__ocs_fmt_0__“ erstellt. + .layout-name-deleted = Layout „__ocs_fmt_0__“ gelöscht. + .layout-manager = Layoutverwaltung .layout-name-cannot-be-empty = Der Layoutname kann nicht leer sein. - .layout-renamed-old-name-new-name = Layout umbenannt: '__ocs_fmt_0__' → '__ocs_fmt_1__' + .layout-renamed-old-name-new-name = Layout umbenannt: „__ocs_fmt_0__“ → „__ocs_fmt_1__“ .leader = Führungslinie .leader-format = Führungslinienformat .leader-structure = Struktur der Führungslinie .leaders = Führungslinien - .leading-trailing = Leading und Trailing + .leading-trailing = Führung und Verlauf .left = Links .left-attachment = linke Befestigung: .left-to-right-dimtxtdirection = Links nach rechts (DIMTXTDIRECTION) @@ -582,91 +583,91 @@ common = .linetype-scale = Streckenmaßstab .lineweight-title-case = Linienstärke .list = Liste - .loaded = Beladen - .loading-discussions = Ladegespräche... - .loading-videos = Videos laden... - .load = Belastung ... + .loaded = Geladen + .loading-discussions = Lade Diskussionen... + .loading-videos = Lade Videos... + .load = Laden ... .low = niedrig .measure-entity-type-not-supported-or-distance = MEASURE: Objekttyp wird nicht unterstützt oder Abstand ist zu groß. .measure-count-point-s-placed = MEASURE: __ocs_fmt_0__ Punkt(e) platziert. .mleaderalign-leaders-aligned = MLEADERALIGN Führungslinien ausgerichtet. .mleadercollect-leaders-collected = MLEADERCOLLECT Führungslinien gesammelt. .model-uppercase = MODEL - .ms-is-only-available-in-paper-space-layouts = MS ist nur in Papierraumlayouts verfügbar. + .ms-is-only-available-in-paper-space-layouts = MS ist nur in Papierraum-Layouts verfügbar. .mspace = MSPACE .mtext = Mtext - .mview-the-clipping-boundary-has-no-usable-area = MVIEW: Die Clipping-Grenze hat keine nutzbare Fläche. - .magenta = Magen + .mview-the-clipping-boundary-has-no-usable-area = MVIEW: Die Schnittgrenze hat keine nutzbare Fläche. + .magenta = Magenta .manage = Verwalten - .meters = Messgeräte + .meters = Meter .middle = Mitte .middle-center-title-case = Mitte zentriert .middle-left-title-case = Mitte links .middle-right-title-case = Mitte rechts - .middle-center-sentence-case = Mittleres Zentrum + .middle-center-sentence-case = Mitte zentriert .middle-left-sentence-case = Mitte links .middle-right-sentence-case = Mitte rechts .middleleft = Mitte links .middleright = Mitte rechts - .midpoint = Mittelwert + .midpoint = Mittelpunkt .miles = Meilen .millimeters = Millimeter .model-title-case = Modell - .modify = Modifizieren - .move = Bewegung + .modify = Ändern + .move = Bewegen .multiple = Mehrfach .multiplier-dimaltf = Multiplikator (DIMALTF) .name-title-case = Name .name-label-title-case = Name: - .nearest = Nächste + .nearest = Nächstgelegen .new = Neu .no = Nein .no-drawing-open-use-new-or-open-first = Keine Zeichnung geöffnet – zuerst Neu oder Öffnen verwenden. .no-groups-found-for-selected-objects = Keine Gruppen für ausgewählte Objekte gefunden. - .no-plugin-handled-command = Kein Plugin wird bearbeitet: __ocs_fmt_0__ + .no-plugin-handled-command = Kein Plugin bearbeitet: __ocs_fmt_0__ .no-viewport-found-in-this-layout = In diesem Layout wurde kein Ansichtsfenster gefunden. .node = Knotenpunkt .none-title-case = Keine .normal = Normal .nothing-found-at-that-point = Nichts gefunden an diesem Punkt. .nothing-to-redo = Nichts zu wiederholen. - .nothing-to-undo = Nichts zu rückgängig machen. + .nothing-to-undo = Nichts rückgängig zu machen. .ok = OK .object-title-case = Objekt .object-is-on-locked-layer-layer-unlock-the-layer-to-edit-it = Objekt befindet sich auf gesperrter Ebene "__ocs_fmt_0__" - entsperren Sie die Ebene, um es zu bearbeiten. .object-is-on-locked-layer-layer-unlock-the-layer-to-select-or-edit-it = Objekt befindet sich auf gesperrter Ebene "__ocs_fmt_0__" - entsperren Sie die Ebene, um sie auszuwählen oder zu bearbeiten. .oblique = Schräglage (°): .oblique-angle = Schrägwinkel - .off = Inaktiv - .offset = Offset + .off = Aus + .offset = Versatz .on = Ein .open = Öffnen .open-dwg-dxf-not-yet-implemented = DWG/DXF öffnen: noch nicht implementiert. .open-cancelled = Geöffnet abgesagt: "__ocs_fmt_0__" - .open-failed-e = Offen fehlgeschlagen: __ocs_fmt_0__ + .open-failed-e = Öffnen fehlgeschlagen: __ocs_fmt_0__ .open-or-create-a-drawing-to-add-a-layout = Öffnen oder erstellen Sie eine Zeichnung, um ein Layout hinzuzufügen. .open-or-create-a-drawing-to-manage-layer-state = Öffnen oder erstellen Sie eine Zeichnung, um Ebenenzustände zu verwalten. .open-or-create-a-drawing-to-manage-layouts = Öffnen oder erstellen Sie eine Zeichnung, um Layouts zu verwalten. - .open-or-create-a-drawing-to-switch-layouts = Öffnen oder erstellen Sie eine Zeichnung, um Layouts zu wechseln. - .opened-file-location = Geöffneter Dateispeicher: __ocs_fmt_0__ + .open-or-create-a-drawing-to-switch-layouts = Öffnen oder erstellen Sie eine Zeichnung, um zwischen Layouts zu wechseln. + .opened-file-location = Geöffneter Dateispeicherort: __ocs_fmt_0__ .opening-name-size-label = Öffnen "__ocs_fmt_0__" (__ocs_fmt_1__)... .options-title-case = Optionen .origin = Ursprung - .ortho = Orthopädie + .ortho = Lotrecht .output = Ausgabe .outside = Außen .outside-limits = Außerhalb der Grenzen. .overall-scale = Globale Skalierung .overwrite = Überschreiben .pedit-applied = PEDIT: angewendet. - .pedit-cannot-convert-this-entity = PEDIT: Dieses Objekt kann nicht konvertiert werden. + .pedit-cannot-convert-this-entity = PEDIT: Dieses Objekt kann nicht umgewandelt werden. .pedit-operation-not-applicable-to-this-entity = PEDIT: Der Vorgang ist auf dieses Objekt nicht anwendbar. .perf = PERF .polysolid-built-a-wall-solid-from-used-segment = POLYSOLID: eine Wand aus __ocs_fmt_0__ Segment(s) gebaut (Breite __ocs_fmt_1__, Höhe __ocs_fmt_2__). .polysolid-select-a-polyline-first = POLYSOLID: Wählen Sie zuerst eine Polylinie aus. .polysolid-the-polyline-has-no-usable-segments = POLYSOLID: Die Polylinie hat keine verwertbaren Segmente. .pspace = PSPACE - .pyramid-could-not-build-all-faces = PYRAMID: konnte nicht alle Gesichter bauen. + .pyramid-could-not-build-all-faces = PYRAMID: konnte nicht alle Seitenflächen bauen. .pyramid-created-a-n-sided-pyramid-radius-radiu = PYRAMID: eine __ocs_fmt_0__-seitige Pyramide (Radius __ocs_fmt_1__, Höhe __ocs_fmt_2__) erstellt. .pyramid-radius-and-height-must-be-positive = PYRAMID: Radius und Höhe müssen positiv sein. .pyramid-tessellation-failed = PYRAMID: Tessellation fehlgeschlagen. @@ -674,13 +675,13 @@ common = .page-setup = Seiteneinrichtung .paper = Papier .parallel = Parallel - .parsing-entities = Objekte werden analysiert… + .parsing-entities = Objekte werden ausgewertet… .paste = Einfügen .perpendicular = Rechtwinklig - .photometric = Photometrische + .photometric = Photometrisch .pick-a-point = Wählen Sie einen Punkt .pick-an-object = Wählen Sie ein Objekt - .pinned = gepinnt + .pinned = angeheftet .place-text-manually-dimupt = Text manuell einfügen (DIMUPT) .plot = Plotten .plot-style = Plotstil @@ -690,7 +691,7 @@ common = .plot-window-x0-2-y0-2-to-x1-2-y1-2 = Plot-Fenster: __ocs_fmt_0__,__ocs_fmt_1__ bis __ocs_fmt_2__,__ocs_fmt_3__ .plot-window-x1-2-y1-2-to-x2-2-y2-2 = Plot-Fenster: __ocs_fmt_0__,__ocs_fmt_1__ bis __ocs_fmt_2__,__ocs_fmt_3__ .plugin-id-failed-to-load-e = Plugin '__ocs_fmt_0__' konnte nicht geladen werden: __ocs_fmt_1__ - .plugin-manager = Plug-in-Manager + .plugin-manager = Plug-in-Verwaltung .point = Punkt .point-cloud = Punktwolken .polyline = Polylinie @@ -714,13 +715,13 @@ common = .quality = Qualität .revolve-could-not-revolve-profile = REVOLVE: konnte das Profil nicht drehen. .revolve-entity-not-found = REVOLVE: Objekt nicht gefunden. - .revolve-solid-created-0 = REVOLVE: solide erzeugt (__ocs_fmt_0__°). + .revolve-solid-created-0 = REVOLVE: Volumenkörper erzeugt (__ocs_fmt_0__°). .radians = Radiant .radius = Radius .ray = Strahl - .read-only-session-read-only-saving-is-disabled = Read-only Session (--read-only): Speichern ist deaktiviert. + .read-only-session-read-only-saving-is-disabled = Schreibgeschütze Sitzung (--read-only): Speichern ist deaktiviert. .realistic = Realistisch - .recent-documents = Jüngste Dokumente + .recent-documents = Letzte Dokumente .recent-files = Zuletzt geöffnete Dateien .recent-file-no-longer-exists = Aktuelle Datei existiert nicht mehr: __ocs_fmt_0__ .red = Rot @@ -737,27 +738,27 @@ common = .requires-single-or-multiple = Erfordert Single oder Multiple. .restore = Wiederherstellung .retry = Versuch - .reverse = Rückwärtsgang + .reverse = Rückwärts .right = Rechts - .right-attachment = Rechten Befestigung: + .right-attachment = Rechte Befestigung: .rotate = Rotieren .rotation = Rotation .round-off-dimaltrnd = Abrunden (DIMALTRND) .round-off-dimrnd = Abrunden (DIMRND) - .section-could-not-build-the-cutting-plane = SECTION: konnte die Schnittebene nicht bauen. - .section-could-not-determine-the-solid-s-extent = SECTION: konnte die Ausdehnung des Feststoffs nicht bestimmen. + .section-could-not-build-the-cutting-plane = SECTION: konnte die Schnittebene nicht erstellen. + .section-could-not-determine-the-solid-s-extent = SECTION: konnte die Ausdehnung des Volumenkörpers nicht bestimmen. .section-created-section-line-s-at-value = SECTION: erstellt __ocs_fmt_0__ Schnittlinie(n) bei __ocs_fmt_1__=__ocs_fmt_2__. - .section-select-exactly-one-solid-created-this = SECTION: Wählen Sie genau einen Solid, der diese Sitzung erstellt hat. + .section-select-exactly-one-solid-created-this = SECTION: Wählen Sie genau einen erstellten Volumenkörper. .section-solid-modelling-is-unavailable-in-this = SECTION: Solid Modeling ist in diesem Build nicht verfügbar. - .section-the-plane-does-not-cross-the-solid = SECTION: Die Ebene kreuzt den Feststoff nicht. - .slice-failed-the-plane-may-not-cross-the-solid = SLICE fehlgeschlagen - das Flugzeug darf den Feststoff nicht kreuzen. - .slice-could-not-determine-the-solid-s-extent = SLICE: konnte die Ausdehnung des Feststoffs nicht bestimmen. + .section-the-plane-does-not-cross-the-solid = SECTION: Die Ebene kreuzt den Volumenkörper nicht. + .slice-failed-the-plane-may-not-cross-the-solid = SLICE fehlgeschlagen - die Ebene darf den Volumenkörper nicht kreuzen. + .slice-could-not-determine-the-solid-s-extent = SLICE: konnte die Ausdehnung des Volumenkörper nicht bestimmen. .slice-cut-at-ax-value-kept-the-half = SLICE: geschnitten bei __ocs_fmt_0__=__ocs_fmt_1__, die Hälfte von __ocs_fmt_2__ beibehalten. - .slice-select-exactly-one-solid-created-this-se = SLICE: Wählen Sie genau einen Solid, der diese Sitzung erstellt hat. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: Die Ebene kreuzt den Festkörper auf der gehaltenen Seite nicht. + .slice-select-exactly-one-solid-created-this-se = SLICE: Wählen Sie genau einen erstellten Volumenkörper. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: Die Ebene kreuzt den Volumenkörper auf der gehaltenen Seite nicht. .splinefit-fit-a-spline-through-n-points = SPLINEFIT: Passen Sie einen Spline durch __ocs_fmt_0__ Punkte. - .splinefit-need-at-least-3-points = SPLINEFIT: mindestens 3 Punkte benötigen. - .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: Wählen Sie eine Polylinie, um eine Spline durchzupassen. + .splinefit-need-at-least-3-points = SPLINEFIT: mindestens 3 Punkte werden benötigt. + .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: Wählen Sie eine Polylinie, um eine Spline einzupassen. .stepout-exported-to = STEPOUT: exportiert nach "__ocs_fmt_0__" .stepout-no-3d-mesh-data-in-this-drawing = STEPOUT: keine 3D-Mesh-Daten in dieser Zeichnung. .stepout-error = STEPOUT: __ocs_fmt_0__ @@ -766,15 +767,15 @@ common = .stlout-error = STLOUT: __ocs_fmt_0__ .stretch-nothing-crosses-the-window = STRETCH: Nichts durchquert das Fenster. .stretch-count-entity-ies-stretched = STRETCH: __ocs_fmt_0__ Objekt(e) gestreckt. - .sweep-could-not-sweep-profile-along-path-use-a = SWEEP: Profil entlang des Pfades nicht kehren konnte, ein geschlossenes 2D-Profil und einen Linien- oder Polylinienpfad verwenden. - .sweep-solid-created = SWEEP: solide erstellt. + .sweep-could-not-sweep-profile-along-path-use-a = SWEEP: Profil entlang des Pfades nicht erkannt, verwende ein geschlossenes 2D-Profil und ein Linien- oder Polylinienpfad. + .sweep-solid-created = SWEEP: Volumenkörper erstellt. .save = Speichern - .save-as = Sparen Sie + .save-as = Speichern als .save-drawing-as = Zeichnung speichern als .save-already-running-for-this-drawing = Diese Zeichnung wird bereits gespeichert. - .save-anyway = Auf jeden Fall sparen + .save-anyway = In jeden Fall speichern .save-error-e = Fehler speichern: __ocs_fmt_0__ - .save-in-source-version = Speichern in der Source Version + .save-in-source-version = Speichern in der Quellversion .save-the-drawing-before-copying-its-file-path = Speichern Sie die Zeichnung, bevor Sie den Dateipfad kopieren. .save-the-drawing-before-opening-its-file-locat = Speichern Sie die Zeichnung, bevor Sie den Dateispeicherort öffnen. .saved-download-but-recent-copy-could-not-be-st = Gespeicherter Download, aber die aktuelle Kopie konnte nicht gespeichert werden: __ocs_fmt_0__ @@ -783,20 +784,20 @@ common = .scene-cleared-standard-linetypes-loaded = Szene geleert; Standardlinientypen geladen. .scientific = Wissenschaftlich .search-layers = Ebenen suchen… - .second-seg-angle = zweiter Nähwinkel: + .second-seg-angle = zweiter Segmentwinkel: .select-all = Alle auswählen .select-similar-added = Wählen Sie Ähnliches: __ocs_fmt_0__ addiert. .select-a-tangent-object = Wählen Sie ein Tangentenobjekt. .select-one-object-first-then-run-objectscale = Wählen Sie zuerst ein Objekt aus und führen Sie dann OBJECTSCALE aus. - .send-feedback = Senden Sie Feedback + .send-feedback = Eine Rückmeldung senden .sent-to-printer-printer = Gesendet an Drucker: __ocs_fmt_0__ .set-current = Aktuell setzen - .shade-plot = Schattenfläche - .shadow-flags = Schattenflaggen + .shade-plot = Schattierter Plot + .shadow-flags = Schattenfahnen .single = Einzel .size = Größe - .snap-override-label-next-pick-only = Snap override: __ocs_fmt_0__ (nur nächster Pick). - .solid-created = Solid geschaffen. + .snap-override-label-next-pick-only = Snap Übersteuerung: __ocs_fmt_0__ (nur nächste Auswahl). + .solid-created = Volumenkörper erstellt. .source-object-not-found = Quellobjekt nicht gefunden. .spacing = Abstand .spacing-dimdli = Abstand (DIMDLI) @@ -817,14 +818,14 @@ common = .support-on-patreon = Unterstützung für Patreon .supporters = Unterstützer .suppress-0-ft-0-in = Unterdrücken Sie Null Fuß und Null Zoll - .suppress-leading = Führende Unterdrückung + .suppress-leading = Leitzeichen unterdrücken .suppress-leading-trailing = Unterdrückung von führenden und nachlaufenden Nullen .suppress-leading-zeros = Unterdrücken Sie führende Nullen .suppress-none = Keine Unterdrückung .suppress-outside-arrows-dimsoxd = Unterdrücken Sie äußere Pfeile (DIMSOXD) - .suppress-trailing = Suppress Trailing - .suppress-trailing-zeros = Unterdrücken Sie nacheilende Nullen - .switched-to-layout-name = Umgeschalten auf Layout '__ocs_fmt_0__'. + .suppress-trailing = Nachlauf unterdrücken + .suppress-trailing-zeros = Unterdrücken Sie nachlaufende Nullen + .switched-to-layout-name = Zu Layout „__ocs_fmt_0__“ gewechselt. .textedit-selected-entity-is-not-text = TEXTEDIT: Das ausgewählte Objekt ist kein Text. .texteditmode-set-to-display-val = TEXTEDITMODE auf __ocs_fmt_0__ gesetzt .table = Tabelle @@ -844,11 +845,11 @@ common = .text-height-label-sentence-case = Texthöhe: .text-movement = Textbewegung .text-movement-dimtmove = Textbewegung (DIMTMOVE) - .text-out-first = Text zuerst raus + .text-out-first = Text abheben .text-style = Textstil - .text-to-find = Text zu finden - .the-selected-object-does-not-support-annotatio = Das ausgewählte Objekt unterstützt keine Annotationsskalen. - .tick-size-dimtsz = Tickgröße (DIMTSZ) + .text-to-find = Text finden + .the-selected-object-does-not-support-annotatio = Das ausgewählte Objekt unterstützt keine Beschriftungsskalierung. + .tick-size-dimtsz = Hakengröße (DIMTSZ) .title = Titel .title-row = Titelzeile: .title-row-suppressed = Titelzeile unterdrückt @@ -857,11 +858,11 @@ common = .toggle-object-snap-tracking = Objektfangspur umschalten .toggle-ortho = Orthomodus umschalten .toggle-polar-tracking = Polarspur umschalten - .tol-decimals-dimalttd = Tol-Dezimalstellen (DIMALTTD) - .tol-zero-supp-dimalttz = Nullunterdrückung der Toleranz (DIMALTTZ) - .tol-decimals-dimtdec = Tol. Dezimalstellen (DIMTDEC) + .tol-decimals-dimalttd = Toleranz - Dezimalstellen (DIMALTTD) + .tol-zero-supp-dimalttz = Toleranz - Nullen ausblenden (DIMALTTZ) + .tol-decimals-dimtdec = Toleranz - Dezimalstellen (DIMTDEC) .tol-vert-just-dimtolj = Vertikale Toleranzausrichtung (DIMTOLJ) - .tol-zero-supp-dimtzin = Nullunterdrückung der Toleranz (DIMTZIN) + .tol-zero-supp-dimtzin = Toleranz - Nullen ausblenden (DIMTZIN) .tolerance = Toleranz .tolerance-precision = Toleranzgenauigkeit .tolerances = Toleranzen @@ -872,23 +873,23 @@ common = .top-center-title-case = Oben zentriert .top-left-title-case = Oben links .top-right-title-case = Oben rechts - .top-attachment = Oberbefestigung: - .top-center-sentence-case = Oberes Zentrum + .top-attachment = Befestigung oben: + .top-center-sentence-case = Oben mittig .top-left-sentence-case = Oben links .top-right-sentence-case = Oben rechts .top-to-bottom = Von oben nach unten - .topcenter = Oberes Zentrum + .topcenter = Oben mittig .topleft = Oben links .topright = Oben rechts .torus = Torus .transparency = Transparenz .truetype-system = TrueType (System) .ttr = Ttr - .tutorials = Lernprogramme + .tutorials = Lerneinheiten .type = Typ - .ucs-world = Welt-BKS + .ucs-world = WKS .ucs = UCS: __ocs_fmt_0__ - .unable-to-change-ui-language-error = Unfähig, die UI Sprache zu ändern: __ocs_fmt_0__ + .unable-to-change-ui-language-error = Unmöglich die UI Sprache zu ändern: __ocs_fmt_0__ .undo = Rückgängig .undo-last-label = Rückgängig: __ocs_fmt_0__ .uniform-scale = Einheitliche Skala @@ -900,7 +901,7 @@ common = .unsaved-changes = Ungespeicherte Änderungen .unsupported-file-type = Nicht unterstützter Dateityp: __ocs_fmt_0__ .up = Aufwärts - .update-available = Update verfügbar + .update-available = Aktualisierung verfügbar .upside-down = Kopfüber nach unten .v-margin = Vertikaler Rand: .vplayer-frozen-count-layer-s-frozen-in-viewpor = VPLAYER: __ocs_fmt_0__ Ebene(n) im Ansichtsfenster eingefroren. @@ -911,7 +912,7 @@ common = .vertical-pos-dimtad = Vertikale Pos (DIMTAD) .vertical-pos-dimtvp = Vertikale Pos (DIMTVP) .videos = Videos - .videos-load-from-the-internet = Videos aus dem Internet geladen. + .videos-load-from-the-internet = Videos aus dem Internet laden. .view = Ansicht .view-home = Ansicht: Start .view-value = Ansicht: __ocs_fmt_0__ @@ -929,7 +930,7 @@ common = .width = Breite .width-factor-title-case = Breitenfaktor .width-factor-label-title-case = Breitenfaktor: - .width-factor-sentence-case = Witwenfaktor + .width-factor-sentence-case = Breitenfaktor .window = Fenster .wipeout = Abdeckung .wireframe = Drahtmodell @@ -939,19 +940,19 @@ common = .xline = XLine .xref-not-found = XREF Nicht gefunden: "__ocs_fmt_0__" (__ocs_fmt_1__) .xref-unloaded-skipped = XREF Unloaded (übersprungen): "__ocs_fmt_0__" - .yards = Gärten + .yards = Yards .yellow = Gelb .yes = Ja - .zero-suppress-dimaltz = Nullunterdrückung (DIMALTZ) - .zero-suppress-dimzin = Nullunterdrückung (DIMZIN) - .zoom-extents = Zoomausdehnungen - .zoom-in = In den Warenkorb - .zoom-out = Ausverkauft - .zoom-window = Zoomfenster + .zero-suppress-dimaltz = Nullen ausblenden (DIMALTZ) + .zero-suppress-dimzin = Nullen ausblenden (DIMZIN) + .zoom-extents = Ausdehnung anpassen + .zoom-in = Vergrößern + .zoom-out = Verkleinern + .zoom-window = Ausdehnungsfenster .command-lowercase = Befehl .e-g-arial = z. B. Arial - .linetype-lowercase = linienförmig - .lineweight-lowercase = Streckengewicht + .linetype-lowercase = Linientyp + .lineweight-lowercase = Liniengewicht .object-lowercase = Objekt .options-lowercase = Optionen .units = Einheiten @@ -960,7 +961,7 @@ common = .label-count-noun-created = __ocs_fmt_0__: __ocs_fmt_1__ __ocs_fmt_2__ erstellt. .acquired = __ocs_fmt_0__ erworben. .alias-es-applied = __ocs_fmt_0__ Aliasname(n) angewendet. - .group-s-dissolved = __ocs_fmt_0__ Gruppe(n) gelöst. + .group-s-dissolved = __ocs_fmt_0__ Gruppe(n) aufgelöst. .layer-s-turned = __ocs_fmt_0__ Layer(s) gedreht __ocs_fmt_1__ .layer-s = __ocs_fmt_0__ Ebene(n) __ocs_fmt_1__ .mod-shortcuts = ── __ocs_arg_MOD__-Tastenkürzel ──────────────────────── @@ -982,22 +983,22 @@ command = .cancel = *Abbrechen* .add-alias = + Aliasnamen hinzufügen .alias-title-case = Aliasname - .cannot-add-a-vertex-here = Kann hier keinen Scheitelpunkt hinzufügen. - .cannot-break-at-this-vertex = Kann nicht an diesem Scheitelpunkt brechen. - .cannot-place-the-new-vertex = Kann den neuen Scheitelpunkt nicht platzieren. + .cannot-add-a-vertex-here = Kann hier keinen Stützpunkt hinzufügen. + .cannot-break-at-this-vertex = Kann nicht an diesem Stützpunkt brechen. + .cannot-place-the-new-vertex = Kann den neuen Stützpunkt nicht platzieren. .command = Befehl - .command-aliases = Befehls Aliasnamen + .command-aliases = Befehls-Aliasnamen .key = Schlüssel .keyboard-shortcuts = Tastenkombinationen .layer-0-and-the-current-layer-can-t-be-deleted = Layer "0" und die aktuelle Ebene können nicht gelöscht werden - übersprungen. .no-drawing-open-use-new-or-open-to-start-a-dra = Verwenden Sie NEW oder OPEN, um eine Zeichnung zu starten. .object-is-on-locked-layer-layer-unlock-the-lay = Objekt befindet sich auf gesperrter Ebene "__ocs_fmt_0__" - sperren Sie die Ebene frei, um ihre Attribute zu bearbeiten. .pan-ended = PAN beendet. - .polyline-broken-at-vertex = Polylinie gebrochen an Scheitelpunkt. + .polyline-broken-at-vertex = Polylinie am Stützpunkt gebrochen. .rename-select-the-object-type-to-rename = RENAME Wählen Sie den umzubenennenden Objekttyp: .rename-type-enter-the-current-name = RENAME __ocs_arg_type__ Geben Sie den aktuellen Namen ein: .rename-type-rename-old-to = RENAME __ocs_arg_type__ „__ocs_arg_old__“ umbenennen in: - .specify-new-vertex-location = Neue Scheitelpunkt-Position angeben: + .specify-new-vertex-location = Neue Stützpunktposition angeben: .type-shortcuts-set-key-cmd-to-add-custom-short = Geben Sie SHORTCUTS SET ein, um benutzerdefinierte Verknüpfungen hinzuzufügen. .type-an-alias-and-the-command-it-runs-e-g-l-li = Alias und zugehörigen Befehl eingeben (z. B. L → LINE). Mit Anwenden in ocad.pgp speichern; Schließen verwirft nicht angewendete Änderungen. .unknown-command-cmd = Unbekannter Befehl: __ocs_fmt_0__ @@ -1010,21 +1011,21 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Platzierung [Überschreiben/Präfix/Suffix] <Überschreiben>: file = - .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = parse __ocs_fmt_0__ms · Purge __ocs_fmt_1__ms · Caches __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · total __ocs_fmt_4__ms + .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analysiere __ocs_fmt_0__ms · Säubere __ocs_fmt_1__ms · Puffer __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · gesamt __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: Speichern Sie die Zeichnung zuerst (sie hat noch keinen Dateipfad). .autosaved-1-drawing = 1 Zeichnung automatisch gespeichert .background-colour-default-black-darkgray-gray = BACKGROUND Farbe [Standard/Black/DarkGray/Gray/LightGray/White] oder R G B (0–255): - .background-reset-to-default = Background auf Standard zurückgesetzt. + .background-reset-to-default = Hintergrund auf Standard zurückgesetzt. .background-rgb = Hintergrund: rgb(__ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__) .created-new-ctb-table-aci-aci-updated = Neue CTB Tabelle erstellt, ACI __ocs_fmt_0__ aktualisiert. .default-save-format = Standard-Speicherformat: .do-you-want-to-save-changes-to-name = Möchten Sie Änderungen an "__ocs_arg_name__" speichern? .drawing-changed-on-disk = Zeichnung auf dem Datenträger geändert .drawing-units-insunits-click-to-change = - Zeicheneinheiten (INSUNITS) - Klicken Sie auf Change - .drawing-changed-on-disk-while-it-was-opening-s = Zeichnen auf der Festplatte geändert, während es geöffnet wurde; Speichern erfordert Konfliktlösung. - .edit-lease-active-warning-external-change-chec = Leasing aktiv bearbeiten; __ocs_fmt_0__. Externe Wechselprüfungen bleiben aktiv. + Zeichnungseinheiten (INSUNITS) + Klicken Sie auf Ändern + .drawing-changed-on-disk-while-it-was-opening-s = Zeichnung während es geöffnet wurde auf der Festplatte geändert; Speichern erfordert Konfliktlösung. + .edit-lease-active-warning-external-change-chec = Pacht aktiv bearbeiten; __ocs_fmt_0__. Externe Wechselprüfungen bleiben aktiv. .files-you-open-will-show-up-here = Dateien, die Sie öffnen, werden hier angezeigt. .new-drawing = Neue Zeichnung .no-plot-style-table-loaded-load-or-create-one = Keine Plotstiltabelle geladen. Laden oder erstellen Sie eine Tabelle. @@ -1032,22 +1033,22 @@ file = .oops-nothing-to-restore = OOPS: nichts zu restaurieren. .oops-restored-n-object-s = OOPS: Wiederherstellung von __ocs_fmt_0__ Objekt(en). .open-cad-studio-ready = Open CAD Studio ist bereit. - .open-discussions-on-github = Offene Diskussionen zu GitHub - .open-file-title-case = Offene Datei + .open-discussions-on-github = Öffne Diskussionen auf GitHub + .open-file-title-case = Öffne Datei .open-file-picker-title-case = Datei öffnen… .open-and-save = Öffnen und Speichern .open-playlist-on-youtube = Wiedergabeliste auf YouTube öffnen .opened-name-entity-count-entities = „__ocs_fmt_0__“ geöffnet — __ocs_fmt_1__ Objekte .opened-printer-properties = Geöffnete Druckereigenschaften. - .opened-read-only-against-other-editors-error = Read-only gegen andere Editoren geöffnet: __ocs_fmt_0__ + .opened-read-only-against-other-editors-error = Nur-Lese-Zugriff von anderem Bearbeiter geöffnet: __ocs_fmt_0__ .page-setup-applied-to-the-layout = Seiteneinrichtung auf das Layout angewendet. - .path-type = Art der Trassen: + .path-type = Art des Pfades: .plot-area-is-empty-pick-a-larger-window = Die Fläche ist leer. Wählen Sie ein größeres Fenster. - .plot-style-aci-aci-updated = Plotstyle ACI __ocs_fmt_0__ aktualisiert. - .plot-style-table-is-not-loaded = Die Plot-Tabelle '__ocs_fmt_0__' wird nicht geladen. + .plot-style-aci-aci-updated = Plotstil ACI __ocs_fmt_0__ aktualisiert. + .plot-style-table-is-not-loaded = Die Plotstil-Tabelle '__ocs_fmt_0__' wird nicht geladen. .quick-print-works-in-model-space = Schnelldruck ist nur im Modellbereich verfügbar. - .reload-failed-error = Reload fehlgeschlagen: __ocs_fmt_0__ - .saveall-save-each-tab-individually-in-the-web = SAVEALL: Speichern Sie jeden Tab einzeln im Web Build. + .reload-failed-error = Nachladen fehlgeschlagen: __ocs_fmt_0__ + .saveall-save-each-tab-individually-in-the-web = SAVEALL: Speichern Sie jeden Reiter einzeln im Web Build. .saveall-saved-saved-drawing-s = SAVEALL: gespeichert __ocs_fmt_0__ Zeichnung(en)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: Für __ocs_fmt_0__ wird bereits gespeichert .saveall-failed-e = SAVEALL: __ocs_fmt_0__ fehlgeschlagen: __ocs_fmt_1__ @@ -1063,15 +1064,15 @@ file = .selection-has-no-printable-area = Die Auswahl hat keinen bedruckbaren Bereich. .selection-has-no-printable-geometry = Die Auswahl hat keine druckbare Geometrie. .sending-to-system-printer = Senden an Systemdrucker... - .type-a-command-or-use-the-ribbon-open-obj-inse = Geben Sie einen Befehl ein oder verwenden Sie das Menüband. Öffnen Sie den Tab OBJ: INSERT. + .type-a-command-or-use-the-ribbon-open-obj-inse = Geben Sie einen Befehl ein oder verwenden Sie das Menüband. Öffnen Sie den Reiter OBJ: INSERT. .unable-to-acquire-edit-lock-error = Unfähig, edit lock zu erwerben: __ocs_fmt_0__ .unable-to-save-file-name-file-is-in-use-by-ano = Kann "__ocs_fmt_0__" nicht speichern: Die Datei wird von einer anderen Anwendung verwendet. .unable-to-save-error = Kann "__ocs_fmt_0__" nicht speichern: __ocs_fmt_1__ - .usage-script-path-to-scr-file = Verwendung: SCRIPT - .usage-undo-number-of-steps = Verwendung: UNDO [number of steps] + .usage-script-path-to-scr-file = Anwendung: SCRIPT + .usage-undo-number-of-steps = Anwendung: UNDO [number of steps] .used-for-the-first-save-of-a-new-drawing-exist = Wird für die erste Speicherung einer neuen Zeichnung verwendet. Bestehende Zeichnungen behalten ihren Dateityp und ihre Version bei. .visual-style-label = Visueller Stil: __ocs_fmt_0__. - .warning-corrupt-entities-dropped-parser-junk-b = Warnung: __ocs_fmt_0__ beschädigte Objekte verworfen (ungültige Parserdaten — fehlerhafte Normalen oder Anzahlen) + .warning-corrupt-entities-dropped-parser-junk-b = Warnung: __ocs_fmt_0__ beschädigte Objekte verworfen (ungültige Analysedaten — fehlerhafte Normalen oder Anzahlen) .warning-corrupt-xref-entities-dropped = Warnung: __ocs_fmt_0__ beschädigte XRef-Objekte verworfen .xref-loaded = XREF Geladen "__ocs_fmt_0__" .cmd-cannot-create-folder-e = __ocs_fmt_0__: kann keinen Ordner erstellen (__ocs_fmt_1__). @@ -1094,11 +1095,11 @@ inquiry = .addselected-selected-object-not-found = ADDSELECTED: ausgewähltes Objekt nicht gefunden. .add-selected-drawing-a-new-kind-on-layer-layer = Auswahl hinzufügen: Neues Objekt vom Typ __ocs_fmt_0__ auf Ebene „__ocs_fmt_1__“ zeichnen. .angle-angle = Winkel = __ocs_arg_angle__° - .angle-0-0000-degenerate-rays = Winkel = 0.0000° (entartete Strahlen) + .angle-0-0000-degenerate-rays = Winkel = 0.0000° (zerfallende Strahlen) .area-area-perimeter-perimeter = Fläche = __ocs_arg_area__, Umfang = __ocs_arg_perimeter__ .bedit-block-edit-discarded = BEDIT: Block "__ocs_fmt_0__" edit verworfen. .bedit-block-saved-all-references-updated = BEDIT: Block "__ocs_fmt_0__" gespeichert. Alle Referenzen werden aktualisiert. - .bedit-editing-block-use-save-block-or-discard = BEDIT: Bearbeiten des Blocks "__ocs_fmt_0__". Verwenden Sie Speicherblock oder Discard, um ihn zu beenden. + .bedit-editing-block-use-save-block-or-discard = BEDIT: Bearbeiten des Blocks "__ocs_fmt_0__". Verwenden Sie Block speichern oder verwerfen um ihn zu beenden. .bedit-block-not-found = BEDIT: Block "__ocs_fmt_0__" nicht gefunden. .bedit-cannot-edit-an-external-reference-xref = BEDIT: kann keine externe Referenz (xref) bearbeiten. .bedit-finish-the-active-refedit-refclose-first = BEDIT: Beenden Sie zuerst die aktive REFEDIT (REFCLOSE). @@ -1114,10 +1115,10 @@ inquiry = .dblist-drawing-has-no-entities = DBLIST: Zeichnung enthält keine Objekte. .dblist-count-entit = DBLIST: __ocs_fmt_0__ Objekt(e). .dist-specify-first-point = DIST Geben Sie den ersten Punkt an: - .dist-specify-second-point = DIST Zweiter Punkt angeben: + .dist-specify-second-point = DIST Zweiten Punkt angeben: .divide-enter-number-of-segments = DIVIDE Geben Sie die Anzahl der Segmente ein: .divide-select-object-to-divide = DIVIDE Objekt auswählen, um zu teilen: - .deselected = Ausgewählt. + .deselected = Abgewählt. .distance-dist-angle-in-xy-plane-angle-xy-delta = Abstand = __ocs_arg_dist__, Winkel in XY Ebene = __ocs_arg_angle_xy__° Delta X = __ocs_arg_dx__, Delta Y = __ocs_arg_dy__, Delta Z = __ocs_arg_dz__ @@ -1128,82 +1129,82 @@ inquiry = .find-not-found = FIND: "__ocs_fmt_0__" nicht gefunden. .find-specify-search-text = FIND: Suchtext angeben. .find-match-es-for = FIND: __ocs_fmt_0__ match(es) für "__ocs_fmt_1__". - .findall-text-to-find-add-replace-text-by-typin = FINDALL Text zu finden (REPLACE durch Eingabe hinzufügen): + .findall-text-to-find-add-replace-text-by-typin = FINDALL Text auffinden (REPLACE durch Eingabe hinzufügen): .flatten-no-entities = FLATTEN: Keine Objekte ausgewählt. .flatten-entity-ies-moved-to-z-0 = FLATTEN: __ocs_fmt_0__ Objekt(e) auf Z=0 verschoben. .id-specify-point = ID Anzugeben ist: - .list-no-entities-selected-select-entities-firs = LIST: keine Entitäten ausgewählt. + .list-no-entities-selected-select-entities-firs = LIST: keine Objekte ausgewählt. .massprop-no-entities-selected-select-entities = MASSPROP: Keine Objekte ausgewählt. .measure-select-object-to-measure = MEASURE Objekt auswählen, um zu messen: .measure-specify-segment-length = MEASURE Segmentlänge angeben: - .measuregeom-enter-an-option-distance-radius-an = MEASUREGEOM Geben Sie eine Option ein [Abstand/Radius/Angle/ARea]: + .measuregeom-enter-an-option-distance-radius-an = MEASUREGEOM Geben Sie eine Option ein [Distance/Radius/Angle/ARea]: .measuregeom-select-arc-or-circle = MEASUREGEOM Bogen oder Kreis auswählen: .measuregeom-specify-first-corner-point-enter-t = MEASUREGEOM Ersten Eckpunkt angeben (Eingabe zum Abbrechen): .measuregeom-specify-first-point = MEASUREGEOM Geben Sie den ersten Punkt an: .measuregeom-specify-first-ray-point = MEASUREGEOM Erststrahlpunkt angeben: .measuregeom-specify-next-point-n-picked-enter = MEASUREGEOM Nächster Punkt angeben (__ocs_arg_n__ ausgewählt, Geben Sie ein, um zu berechnen): - .measuregeom-specify-second-point = MEASUREGEOM Zweiter Punkt angeben: - .measuregeom-specify-second-ray-point = MEASUREGEOM Zweiter Strahlpunkt angeben: - .measuregeom-specify-vertex-point = MEASUREGEOM Scheitelpunkt-Punkt angeben: + .measuregeom-specify-second-point = MEASUREGEOM Zweiten Punkt angeben: + .measuregeom-specify-second-ray-point = MEASUREGEOM Zweiten Strahlpunkt angeben: + .measuregeom-specify-vertex-point = MEASUREGEOM Stützpunkt angeben: .measure-geometry = Geometrie der Messwerte - .qselect-no-matching-entities = QSELECT: keine passenden Entitäten. + .qselect-no-matching-entities = QSELECT: keine passenden Objekte. .qselect-entity-ies-selected = QSELECT: __ocs_fmt_0__ Objekt(e) ausgewählt. .refclose-block-saved-all-references-updated = REFCLOSE: Block "__ocs_fmt_0__" gespeichert. Alle Referenzen werden aktualisiert. .refclose-changes-discarded = REFCLOSE: Änderungen verworfen. .refclose-no-refedit-session-active = REFCLOSE: keine REFEDIT Sitzung aktiv. - .refedit-editing-block-run-refclose-to-save-ref = REFEDIT: Bearbeiten Block "__ocs_fmt_0__". Laufen Sie REFCLOSE zu speichern, REFCLOSE_DISCARD zu annullieren. - .refedit-a-session-is-already-active-use-refclo = REFEDIT: Eine Sitzung ist bereits aktiv. Verwenden Sie REFCLOSE zuerst. + .refedit-editing-block-run-refclose-to-save-ref = REFEDIT: Block "__ocs_fmt_0__" earbeiten. REFCLOSE aufrufen zum speichern, REFCLOSE_DISCARD zum verwerfen. + .refedit-a-session-is-already-active-use-refclo = REFEDIT: Eine Sitzung ist bereits aktiv. Verwenden Sie zuerst REFCLOSE. .refedit-block-not-found = REFEDIT: Block "__ocs_fmt_0__" nicht gefunden. .refedit-block-is-empty = REFEDIT: Block ist leer. .refedit-selected-object-is-not-an-insert = REFEDIT: Ausgewähltes Objekt ist kein INSERT. .radius-radius-diameter-diameter = Radius = __ocs_arg_radius__, Durchmesser = __ocs_arg_diameter__ .selectall-object-s-selected = SELECTALL: __ocs_fmt_0__ Objekt(e) ausgewählt. - .selhandles-no-entities-selected-select-entitie = SELHANDLES: keine Entitäten ausgewählt. - .usage-cal-expression-e-g-cal-2-3-4 = Verwendung: CAL e.g. CAL (2+3)*4 - .usage-qselect-type-layer-color-linetype-value = Verwendung: QSELECT TYPE|LAYER|COLOR|LINETYPE + .selhandles-no-entities-selected-select-entitie = SELHANDLES: keine Objekte ausgewählt. + .usage-cal-expression-e-g-cal-2-3-4 = Anwendung: CAL e.g. CAL (2+3)*4 + .usage-qselect-type-layer-color-linetype-value = Anwendung: QSELECT TYPE|LAYER|COLOR|LINETYPE .x-x-y-y-z-z = X = __ocs_arg_x__, Y = __ocs_arg_y__, Z = __ocs_arg_z__ .type-name-handle-x-layer-color-lt = __ocs_fmt_0__ Griff:__ocs_fmt_1__ Ebene:__ocs_fmt_2__ Farbe:__ocs_fmt_3__ LT:__ocs_fmt_4____ocs_fmt_5__ - .area-4-perimeter-4-centroid-4-4 = __ocs_fmt_0__ Fläche=__ocs_fmt_1__ Perimeter=__ocs_fmt_2__ Centroid=(__ocs_fmt_3__,__ocs_fmt_4__) + .area-4-perimeter-4-centroid-4-4 = __ocs_fmt_0__ Fläche=__ocs_fmt_1__ Umfang=__ocs_fmt_2__ Schwerpunkt=(__ocs_fmt_3__,__ocs_fmt_4__) draw = .insert-x-no-attributes = __ocs_fmt_0__ einfügen: keine Attribute. .tag-val = [__ocs_fmt_0__] = __ocs_fmt_1__ .no-closed-boundary-found = ⚠ Keine geschlossene Begrenzung gefunden. - .inverted = , invertiert + .inverted = , vertauscht .value-2-point = 2-Punkt - .value-2d-solid = 2D Festkörper + .value-2d-solid = 2D Volumenkörper .value-3-point = 3-Punkt - .value-3d-polyline = 3D-Polylin - .value-3dmirror-mirror-plane-x-y-z = 3DMIRROR Spiegelebene [X/Y/Z]: + .value-3d-polyline = 3D-Polylinie + .value-3dmirror-mirror-plane-x-y-z = 3DMIRROR Spiegelebene [X/Y/Z]: .value-3dpoly-specify-next-point-close-undo = 3DPOLY Nächsten Punkt angeben [Schließen/Zurück]: .value-3dpoly-specify-next-point-undo = 3DPOLY Nächsten Punkt angeben [Zurück]: .value-3dpoly-specify-start-point-of-polyline = 3DPOLY Beginn der Polylinie angeben: - .value-3drotate-angle-in-degrees = 3DROTATE Winkel in Grad: - .value-3drotate-rotation-axis-x-y-z = 3DROTATE Drehachse [X/Y/Z]: + .value-3drotate-angle-in-degrees = 3DROTATE Winkel in Grad: + .value-3drotate-rotation-axis-x-y-z = 3DROTATE Drehachse [X/Y/Z]: .arc-specify-center = ARC Zentrum angeben: .arc-specify-end-point-c-cx-cy-r-r = ARC Endpunkt angeben [c=(__ocs_arg_cx__,__ocs_arg_cy__) r=__ocs_arg_r__]: .arc-specify-start-point = ARC Startpunkt angeben: .arc-3p-specify-end-point = ARC 3P Endpunkt angeben: - .arc-3p-specify-second-point-on-arc = ARC 3P Zweiter Punkt auf dem Bogen angeben: + .arc-3p-specify-second-point-on-arc = ARC 3P Zweiten Punkt auf dem Bogen angeben: .arc-3p-specify-start-point = ARC 3P Startpunkt angeben: - .arc-csa-click-end-direction-or-type-arc-span-i = ARC CSA Klicken Sie auf die Endrichtung oder Typ Bogenspanne in Grad [start=__ocs_arg_sa__]: - .arc-csa-specify-center = ARC CSA Zentrum angeben: + .arc-csa-click-end-direction-or-type-arc-span-i = ARC CSA Klicken Sie auf die Endrichtung oder Typ Bogen in Grad [start=__ocs_arg_sa__]: + .arc-csa-specify-center = ARC CSA Mittelpunkt angeben: .arc-csa-specify-start-point = ARC CSA Startpunkt angeben: - .arc-csl-click-chord-end-or-type-chord-length-r = ARC CSL Akkordende oder Akkordlänge eingeben [r=__ocs_arg_r__]: - .arc-csl-specify-center = ARC CSL Zentrum angeben: + .arc-csl-click-chord-end-or-type-chord-length-r = ARC CSL Sehnenende oder Sehnenlänge eingeben [r=__ocs_arg_r__]: + .arc-csl-specify-center = ARC CSL Mittelpunkt angeben: .arc-csl-specify-start-point = ARC CSL Startpunkt angeben: - .arc-continue-no-previous-line-or-arc-to-contin = ARC Fortfahren Keine vorherige Linie oder Bogen, um fortzufahren. - .arc-continue-specify-end-point-ctrl-flip-direc = ARC Weiter Endpunkt angeben [Strg = Flip-Richtung]: - .arc-sca-click-end-direction-or-type-arc-span-i = ARC SCA Klicken Sie auf die Endrichtung oder Typ Bogenspanne in Grad [start=__ocs_arg_sa__]: - .arc-sca-specify-center = ARC SCA Zentrum angeben: + .arc-continue-no-previous-line-or-arc-to-contin = ARC Fortfahren, keine vorherige Linie oder Bogen, um fortzufahren. + .arc-continue-specify-end-point-ctrl-flip-direc = ARC Forfahren, Endpunkt angeben [Strg = Flip-Richtung]: + .arc-sca-click-end-direction-or-type-arc-span-i = ARC SCA Klicken Sie auf die Endrichtung oder Typ Bogen in Grad [start=__ocs_arg_sa__]: + .arc-sca-specify-center = ARC SCA Mittelpunkt angeben: .arc-sca-specify-start-point = ARC SCA Startpunkt angeben: - .arc-sce-specify-center = ARC SCE Zentrum angeben: + .arc-sce-specify-center = ARC SCE Mittelpunkt angeben: .arc-sce-specify-end-point-r-r = ARC SCE Endpunkt angeben [r=__ocs_arg_r__]: .arc-sce-specify-start-point = ARC SCE Startpunkt angeben: - .arc-scl-click-chord-end-or-type-chord-length-r = ARC SCL Akkordende oder Akkordlänge eingeben [r=__ocs_arg_r__]: - .arc-scl-specify-center = ARC SCL Zentrum angeben: + .arc-scl-click-chord-end-or-type-chord-length-r = ARC SCL Sehnenende oder Sehnenlänge eingeben [r=__ocs_arg_r__]: + .arc-scl-specify-center = ARC SCL Mittelpunkt angeben: .arc-scl-specify-start-point = ARC SCL Startpunkt angeben: - .arc-sea-specify-angle-move-cursor-perpendicula = ARC SEA Winkel angeben (Cursor senkrecht zum Akkord verschieben): + .arc-sea-specify-angle-move-cursor-perpendicula = ARC SEA Winkel angeben (Eingabezeiger senkrecht zum Bezug verschieben): .arc-sea-specify-end-point = ARC SEA Endpunkt angeben: .arc-sea-specify-start-point = ARC SEA Startpunkt angeben: .arc-sed-specify-end-point = ARC SED Endpunkt angeben: @@ -1220,7 +1221,7 @@ draw = .attdef-specify-insertion-point-for-tag = ATTDEF Einfügungspunkt für '__ocs_arg_tag__' angeben: .attdisp-attribute-display-on-off-normal = ATTDISP Attributanzeige [Ein/Aus/Normal]: .attdisp-sub-count-attribute-definition-s-updat = ATTDISP __ocs_fmt_0__: __ocs_fmt_1__ Attributdefinition(en) aktualisiert. - .attedit-no-insert-entities-in-selection = ATTEDIT: keine Entitäten in die Auswahl einfügen. + .attedit-no-insert-entities-in-selection = ATTEDIT: keine Objekte in die Auswahl einfügen. .attedit-select-an-insert-entity-first = ATTEDIT: Zuerst eine Blockreferenz auswählen. .attedit-tag-tag-up-not-found-in-selection = ATTEDIT: tag '__ocs_fmt_0__' nicht in der Auswahl gefunden. .attedit-updated-changed-attribute-s-tag-up-new = ATTEDIT: aktualisiertes __ocs_fmt_0__ Attribut(s) [__ocs_fmt_1__] = __ocs_fmt_2__. @@ -1247,8 +1248,8 @@ draw = .circle-ttt-select-second-tangent-object = CIRCLE TTT Wähle das zweite Tangentenobjekt aus: .circle-ttt-select-third-tangent-object = CIRCLE TTT Wähle drittes Tangentenobjekt: .copy-specify-base-point-count-objects = COPY Basispunkt angeben [__ocs_arg_count__ Objekte]: - .center-line = Zentrumlinie - .center-mark = Zentrum Mark + .center-line = Mittellinie + .center-mark = Mittelpunktmarkierung .center-axes = Zentrum, Achsen .center-diameter = Mitte, Durchmesser .center-radius = Mitte, Radius @@ -1262,14 +1263,14 @@ draw = .donut-specify-center-of-donut-enter-to-exit = DONUT Mittelpunkt des Rings angeben (Eingabe zum Beenden): .donut-specify-inside-diameter-0 = DONUT Innendurchmesser <0> angeben: .donut-specify-outside-diameter = DONUT Außendurchmesser angeben: - .draw-manually = Handzeichen - .ellipse-specify-center = ELLIPSE Zentrum angeben: - .ellipse-specify-major-axis-endpoint = ELLIPSE Endpunkt der Hauptachse angeben: - .ellipse-specify-minor-axis-point-or-type-half = ELLIPSE Kleiner Achsenpunkt oder Typhalblänge angeben [Hauptachse = __ocs_fmt_0__]: + .draw-manually = Manuell zeichnen + .ellipse-specify-center = ELLIPSE Mittelpunkt angeben: + .ellipse-specify-major-axis-endpoint = ELLIPSE Endpunkt der Hauptachse angeben: + .ellipse-specify-minor-axis-point-or-type-half = ELLIPSE Kleiner Achsenpunkt oder Typhalblänge angeben [Hauptachse = __ocs_fmt_0__]: .ellipse-axis-specify-first-endpoint-of-major-a = ELLIPSE (Achse) Geben Sie den ersten Endpunkt der Hauptachse an: - .ellipse-axis-specify-minor-axis-point-or-type = ELLIPSE (Achse) Kleinerer Achsenpunkt oder Typhalblänge angeben [großes r=__ocs_fmt_0__]: + .ellipse-axis-specify-minor-axis-point-or-type = ELLIPSE (Achse) Kleineren Achsenpunkt oder Typhalblänge angeben [großes r=__ocs_fmt_0__]: .ellipse-axis-specify-second-endpoint-of-major = ELLIPSE (Achse) Geben Sie den zweiten Endpunkt der Hauptachse an: - .ellipse-arc-specify-center = ELLIPSE ARC Zentrum angeben: + .ellipse-arc-specify-center = ELLIPSE ARC Mittelpunkt angeben: .ellipse-arc-specify-end-angle-point-or-type-de = ELLIPSE ARC Endwinkelpunkt oder Typgrad angeben [start=__ocs_arg_sa__]: .ellipse-arc-specify-major-axis-endpoint = ELLIPSE ARC Endpunkt der Hauptachse angeben: .ellipse-arc-specify-minor-axis-point-or-type-h = ELLIPSE ARC Kleinachsenpunkt oder Typhalblänge angeben [Hauptachse r=__ocs_arg_r__]: @@ -1292,18 +1293,18 @@ draw = .helix-specify-centre-point-of-base = HELIX Geben Sie den Mittelpunkt der Basis an: .helix-specify-helix-height = HELIX Helixhöhe angeben: .helix-specify-top-radius-same-as-base = HELIX Geben Sie den oberen Radius an: - .image-specify-insertion-point-name = IMAGE Einführstelle angeben (__ocs_arg_name__): + .image-specify-insertion-point-name = IMAGE Einfügepunkt angeben (__ocs_arg_name__): .image-specify-width-drag-right = IMAGE Breite angeben (rechts abziehen): - .invert-off = Invertiert: ausgeschaltet - .invert-on = Invertieren: am + .invert-off = Invertiert: aus + .invert-on = Invertieren: an .line-specify-first-point = LINE Geben Sie den ersten Punkt an: .line-specify-next-point-close-undo = LINE Nächsten Punkt angeben [Schließen/Zurück]: .line-specify-next-point-undo = LINE Nächsten Punkt angeben [Zurück]: .line-color-aci = Linienfarbe (ACI): .line-type = Linientyp: - .line-weight = Streckengewicht: + .line-weight = Liniengewicht: .linear-factor-dimlfac = Linearer Faktor (DIMLFAC) - .literal-spaces-space-stays-in-the-line-instead = Wörtlich definierte Leerzeichen: Leerzeichen bleiben in der Zeile, anstatt den Befehl auszuführen (das gleiche wie das Eingeben eines führenden '>'). Bleibt ein, bis man abschaltet. + .literal-spaces-space-stays-in-the-line-instead = Wörtlich definierte Leerzeichen: Leerzeichen verbleiben in der Zeile, anstatt den Befehl auszuführen (das gleiche wie das Eingeben eines führenden '>'). Aktiv bis zur Abschaltung. .mline-enter-scale-factor = MLINE Skalierungsfaktor eingeben: .mline-specify-next-point-count-pts-enter-to-fi = MLINE Nächsten Punkt angeben (__ocs_arg_count__ Punkte, Eingabe zum Beenden, C zum Schließen, S zum Festlegen des Maßstabs): .mline-specify-start-point-scale-scale = MLINE Startpunkt angeben (scale=__ocs_arg_scale__): @@ -1312,10 +1313,10 @@ draw = .point-specify-point = POINT Anzugeben ist: .polygon-enter-number-of-sides = POLYGON Geben Sie die Anzahl der Seiten <__ocs_fmt_0__> ein: .polygon-specify-center-sides = POLYGON Mitte angeben [__ocs_fmt_0__ Seiten]: - .polygon-specify-vertex-on-circle-sides-inscrib = POLYGON Scheitelpunkt im Kreis angeben [__ocs_fmt_0__ Seiten eingeschrieben]: - .polygon-c-enter-number-of-sides = POLYGON C Geben Sie die Anzahl der Seiten <__ocs_fmt_0__> ein: - .polygon-c-specify-center-sides = POLYGON C Mitte angeben [__ocs_fmt_0__ Seiten]: - .polygon-c-specify-edge-midpoint-radius-sides-c = POLYGON C Kanten-Mittelpunkt-Radius angeben [__ocs_fmt_0__ Seiten umschrieben]: + .polygon-specify-vertex-on-circle-sides-inscrib = POLYGON Stützpunkt im Kreis angeben [__ocs_fmt_0__ Seiten eingeschrieben]: + .polygon-c-enter-number-of-sides = POLYGON C Geben Sie die Anzahl der Seiten <__ocs_fmt_0__> ein: + .polygon-c-specify-center-sides = POLYGON C Mitte angeben [__ocs_fmt_0__ Seiten]: + .polygon-c-specify-edge-midpoint-radius-sides-c = POLYGON C Kanten-Mittelpunkt-Radius angeben [__ocs_fmt_0__ Seiten umschrieben]: .polygon-e-enter-number-of-sides-n = POLYGON E Geben Sie die Anzahl der Seiten ein <__ocs_arg_n__>: .polygon-e-specify-first-endpoint-of-edge-n-sid = POLYGON E Geben Sie den ersten Endpunkt der Kante an [__ocs_arg_n__ Seiten]: .polygon-e-specify-second-endpoint-of-edge-n-si = POLYGON E Geben Sie den zweiten Endpunkt der Kante an [__ocs_arg_n__ Seiten]: @@ -1325,7 +1326,7 @@ draw = .pyramid-height-add-sides-by-typing-a-3rd-numbe = PYRAMID Höhe (Seiten hinzufügen durch Eingabe einer 3. Zahl): .polygon-circumscribed = Polygon – umschrieben .polygon-edge = Polygon - Rand - .polygon-inscribed = Polygon - Inschrift + .polygon-inscribed = Polygon – eingeschrieben .ray-specify-start-point = RAY Startpunkt angeben: .ray-specify-through-point = RAY Geben Sie durch Punkt an: .rect-specify-first-corner = RECT Geben Sie die erste Ecke an: @@ -1356,7 +1357,7 @@ draw = .revcloud-polygonal-start-point = REVCLOUD Polygonaler Startpunkt (__ocs_arg_style__, Bogenlänge __ocs_arg_length__): __ocs_arg_message__ .revcloud-next-polygonal-point = REVCLOUD Nächster Polygonpunkt (__ocs_arg_count__ Punkte, Enter zum Schließen): __ocs_arg_message__ .revcloud-freehand-first-point = REVCLOUD Erster Freihandpunkt (__ocs_arg_style__, Bogenlänge __ocs_arg_length__): __ocs_arg_message__ - .revcloud-guide-cursor = REVCLOUD Cursor führen, dann klicken oder Enter zum Schließen drücken: __ocs_arg_message__ + .revcloud-guide-cursor = REVCLOUD Eingabezeiger führen, dann klicken oder Eingabe zum Schließen drücken: __ocs_arg_message__ .revcloud-approximate-arc-chord-length = REVCLOUD Ungefähre Bogensehnenlänge <__ocs_arg_length__>: .revcloud-style = REVCLOUD Stil [Normal/Kalligrafie] <__ocs_arg_style__>: .revcloud-select-closed-curve = REVCLOUD Geschlossenen Kreis, Ellipse, Polylinie oder Spline auswählen: __ocs_arg_message__ @@ -1366,9 +1367,9 @@ draw = .revcloud-pick-side = REVCLOUD Zu löschende Seite der ursprünglichen Wolke wählen: .revcloud-path-cannot-form = Der ausgewählte Pfad kann keine Revisionswolke bilden. .revcloud-select-closed-revision-cloud = Eine geschlossene Revisionswolken-Polylinie auswählen. - .revcloud-polyline-not-planar = Die ausgewählte Polylinie ist nicht planar. - .revcloud-corners-non-zero-area = Die beiden Ecken müssen eine Fläche ungleich null definieren. - .revcloud-arc-length-positive = Die Bogenlänge muss größer als null sein. + .revcloud-polyline-not-planar = Die ausgewählte Polylinie ist nicht eben. + .revcloud-corners-non-zero-area = Die beiden Ecken müssen eine Fläche ungleich Null ergeben. + .revcloud-arc-length-positive = Die Bogenlänge muss größer als Null sein. .revcloud-enter-valid-arc-length = Eine gültige Bogenlänge eingeben. .revcloud-select-supported-closed-curve = Eine unterstützte geschlossene Kurve auswählen. .revcloud-select-closed-entity = Einen geschlossenen Kreis, eine Ellipse, Polylinie oder einen Spline auswählen. @@ -1380,12 +1381,12 @@ draw = .rectangle-rotated = Rechteck - rotiert .rectangle-two-corners = Rechteck - Zwei Ecken .rev-cloud = Rev. Cloud - .section-cutting-plane-axis-x-y-z = SECTION Schnittebenenachse [X/Y/Z]: + .section-cutting-plane-axis-x-y-z = SECTION Schnittebenenachse [X/Y/Z]: .section-offset-along-x = SECTION versetzt entlang X: .section-offset-along-y = SECTION längs Y versetzt: .section-offset-along-z = SECTION Versetzt entlang Z: - .sketch-pen-down-move-to-sketch-click-to-lift-e = SKETCH Pen down — zum Skizzieren bewegen, zum Liften klicken, zum Aufzeichnen eingeben: - .sketch-pen-up-click-to-lower-the-pen-enter-to = SKETCH Pen up — klicken Sie auf den Stift zu senken, geben Sie ein, um aufzuzeichnen: + .sketch-pen-down-move-to-sketch-click-to-lift-e = SKETCH Stift unten — zum Skizzieren bewegen, zum anheben klicken, Eingabe zum Aufzeichnen: + .sketch-pen-up-click-to-lower-the-pen-enter-to = SKETCH Stift oben — klicken Sie auf den Stift zum absenken, Eingabe um aufzuzeichnen: .sketch-pen = Stift .sketch-increment = Schrittweite .sketch-record = Aufzeichnen @@ -1427,17 +1428,17 @@ draw = .tan-tan-tan = Tangente, Tangente, Tangente .trace = Band .ttt = Ttt - .usage-attdisp-on-off-normal = Verwendung: ATTDISP ON | OFF | NORMAL - .usage-polysolid-width-height-select-a-polyline = Verwendung: POLYSOLID (select a polyline first) - .usage-pyramid-radius-height-sides-default-4-si = Verwendung: PYRAMID [sides] (default 4 sides) + .usage-attdisp-on-off-normal = Anwendung: ATTDISP ON | OFF | NORMAL + .usage-polysolid-width-height-select-a-polyline = Anwendung: POLYSOLID (select a polyline first) + .usage-pyramid-radius-height-sides-default-4-si = Anwendung: PYRAMID [sides] (default 4 sides) .wipeout-specify-first-point-or-polyline = WIPEOUT Geben Sie den ersten Punkt oder [Rahmen/Polylinie] an : .wipeout-specify-next-point-or-undo-close-n-poi = WIPEOUT Nächsten Punkt angeben oder [Zurück/Schließen] (__ocs_arg_n__ Punkte): - .wipeout-polyline-select-a-closed-planar-polyli = WIPEOUT Polylinie Wählen Sie eine geschlossene planare Polylinie: + .wipeout-polyline-select-a-closed-planar-polyli = WIPEOUT Wählen Sie eine geschlossene planare Polylinie: .wipeout-rectangular-specify-first-corner = WIPEOUT Rechteckig Geben Sie die erste Ecke an: .wipeout-rectangular-specify-opposite-corner = WIPEOUT Rechteckig Angeben der gegenüberliegenden Ecke: - .wipeout-frames-enter-frame-mode = WIPEOUT Rahmen Rahmenmodus eingeben [Aus/Ein/AnzeigenAberNichtPlotten] <__ocs_arg_mode__>: - .wipeout-polyline-erase-source = WIPEOUT Polylinie Quellpolylinie löschen? [Ja/Nein] : - .wipeout-polyline-invalid = WIPEOUT Polylinie: Wählen Sie eine gerade, geschlossene, planare 2D-Polylinie mit mindestens 3 sich nicht schneidenden Scheitelpunkten. + .wipeout-frames-enter-frame-mode = WIPEOUT Modus des Rahmens eingeben [Aus/Ein/AnzeigenAberNichtPlotten] <__ocs_arg_mode__>: + .wipeout-polyline-erase-source = WIPEOUT Quellpolylinie löschen? [Ja/Nein] : + .wipeout-polyline-invalid = WIPEOUT Wählen Sie eine gerade, geschlossene, planare 2D-Polylinie mit mindestens 3 sich nicht schneidenden Stützpunkten. .setvar-frame-range = SETVAR: __ocs_fmt_0__ erfordert 0, 1 oder 2. .frames = Rahmen .display-but-not-plot = Anzeigen, aber nicht plotten @@ -1445,7 +1446,7 @@ draw = .image-adjust = Bildanpassung .x-ray = Röntgenstrahlen .xline-specify-a-point = XLINE Geben Sie einen Punkt an: - .xline-specify-through-point = XLINE Geben Sie durch Punkt an: + .xline-specify-through-point = XLINE Geben Sie den Durchgangspunkt an: .n-object-s-erased = __ocs_fmt_0__ Objekt(e) gelöscht. modify = @@ -1648,9 +1649,9 @@ layers = .layuniso-all-layers-restored = LAYUNISO: Alle Ebenen wiederhergestellt. .layer-freeze = Ebene frieren .layer-lock = Ebene sperren - .layer-manager = Layer-Manager + .layer-manager = Ebenenverwaltung .layer-off = Ebene ausschalten - .layer-state-manager = Layerstatus-Manager + .layer-state-manager = Ebenenstatus-Verwaltung .layer-unlock = Layer Entsperrung .layer-state-name = Name des Ebenenzustands .layer-states-are-stored-inside-the-drawing-and = Ebenenzustände werden innerhalb der Zeichnung gespeichert und bleiben nach dem Wiederöffnen verfügbar. @@ -1799,14 +1800,24 @@ annotate = .collect-leaders = Führungslinien sammeln .ddedit-select-text-entity = DDEDIT Textobjekt auswählen: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED Bemaßungstext eingeben (leer = Messwert): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Lage der Dimensionslinie angeben [Text/Winkel]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED Geben Sie den Ursprung der ersten Erweiterungslinie an: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED Geben Sie den Ursprung der zweiten Erweiterungslinie an [Text/Winkel]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED Formatierten Bemaßungstext eingeben (leer = Messwert): + .dimaligned-select-object-to-dimension = DIMALIGNED Zu bemaßendes Objekt auswählen: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Lage der Bemaßungslinie angeben [Mtext/Text/Winkel]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED Ursprung der ersten Maßhilfslinie angeben oder Enter drücken, um ein Objekt auszuwählen: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED Ursprung der zweiten Maßhilfslinie angeben: .dimaligned-specify-text-angle-degrees = DIMALIGNED Textwinkel angeben (Grad): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR Bemaßungstext eingeben (leer = Messwert): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR Formatierten Bemaßungstext eingeben (leer = Messwert): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR Bogen, Kreis oder Linie wählen oder Winkelspitze angeben: + .dimangular-select-second-line = DIMANGULAR Zweite Linie wählen: .dimangular-specify-angle-vertex = DIMANGULAR Winkelspitze angeben: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR Bemaßungsbogenlage angeben [Mtext/Text/Winkel/Quadrant]: .dimangular-specify-dimension-arc-location = DIMANGULAR Bemaßungsbogenlage angeben: .dimangular-specify-first-extension-line-point = DIMANGULAR Geben Sie den ersten Verlängerungslinienpunkt an: + .dimangular-specify-quadrant = DIMANGULAR Quadrant angeben: .dimangular-specify-second-extension-line-point = DIMANGULAR Zweiter Verlängerungslinienpunkt angeben: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR Zweiten Punkt für Textwinkel angeben: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR Textwinkel oder ersten Punkt angeben: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE Keine Basisdimension gefunden. .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE Ursprung der zweiten Maßhilfslinie angeben (Eingabe zum Beenden): .dimbreak-select-dimension-to-break = DIMBREAK Bemaßung auswählen, um zu brechen: @@ -1814,8 +1825,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE Keine Basisdimension gefunden. .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE Ursprung der zweiten Maßhilfslinie angeben (Eingabe zum Beenden): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER Bemaßungstext eingeben (leer = Messwert): - .dimdiameter-specify-center-point = DIMDIAMETER Mittelpunkt angeben: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER Lage der Dimensionslinie angeben [Text/Winkel]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER Bogen, Kreis oder Polylinienbogen auswählen: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER Lage der Dimensionslinie angeben [MText/Text/Winkel]: .dimdiameter-specify-point-on-circle = DIMDIAMETER Punkt auf dem Kreis angeben: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER Textwinkel angeben (Grad): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT Textüberschreiben eingeben (leer = auf gemessen zurücksetzen): @@ -1831,10 +1842,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR Geben Sie den Ursprung der zweiten Erweiterungslinie an [Text/Winkel]: .dimlinear-specify-text-angle-degrees = DIMLINEAR Textwinkel angeben (Grad): .dimordinate-specify-feature-location = DIMORDINATE Bezugspunkt angeben: - .dimordinate-specify-leader-endpoint = DIMORDINATE Endpunkt der Führungslinie angeben: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE Bemaßungstext eingeben (leer = Messwert): + .dimordinate-specify-leader-endpoint = DIMORDINATE Endpunkt der Führungslinie angeben [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE Textwinkel angeben (Grad): .dimradius-enter-dimension-text-blank-measured = DIMRADIUS Bemaßungstext eingeben (leer = Messwert): - .dimradius-specify-center-point = DIMRADIUS Mittelpunkt angeben: - .dimradius-specify-dimension-line-location-text = DIMRADIUS Lage der Dimensionslinie angeben [Text/Winkel]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Bogen, Kreis oder Polylinienbogen auswählen: + .dimradius-specify-dimension-line-location-text = DIMRADIUS Lage der Dimensionslinie angeben [MText/Text/Winkel]: .dimradius-specify-radius-point = DIMRADIUS Radiuspunkt angeben: .dimradius-specify-text-angle-degrees = DIMRADIUS Textwinkel angeben (Grad): .dimspace-enter-value-0-auto = DIMSPACE Abstand eingeben (0 = automatisch): @@ -1866,6 +1879,7 @@ annotate = Daten .lower = niedriger .mleader-specify-arrowhead-point = MLEADER Pfeilspitze angeben: + .mleader-specify-landing-point = MLEADER Absetzpunkt angeben: .mleader-specify-next-point-count-pts-enter-to = MLEADER Nächsten Punkt angeben [__ocs_arg_count__ Punkte — Eingabe zum Platzieren des Texts]: .mleaderadd-select-a-multileader = MLEADERADD Multiführungslinie auswählen: .mleaderadd-specify-arrowhead-location = MLEADERADD Pfeilkopfposition angeben: @@ -1938,7 +1952,7 @@ insert = .attsync-no-block-named-arg = ATTSYNC: kein Block mit dem Namen "__ocs_fmt_0__". .attsync-synchronised-synced-insert-s-of-block = ATTSYNC: synchronisierte __ocs_fmt_0__ Einfügen(e) von "__ocs_fmt_1__" gegen __ocs_fmt_2__ Attributdefinition(en). .adjust = Anpassung - .array-insert = Array-Einsatz + .array-insert = Array einfügen .attach = Anlage .attach-xref = XREF anhängen .attribute-editor = Attribut Editor @@ -2033,6 +2047,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ Objekt(e) in die Zwischenablage geschnitten. model = + .create = Erstellen + .boolean = Boolesche Operationen + .pyramid = Pyramide + .polysolid = Polykörper + .extrude = Extrudieren + .revolve = Drehen + .loft = Loft + .sweep = Austragen + .presspull = Drücken/Ziehen .n-specify-center-point = __ocs_arg_n__ Mittelpunkt angeben: .n-specify-first-corner = __ocs_arg_n__ Erste Ecke angeben: .n-specify-height-enter-for-default = __ocs_arg_n__ Höhe angeben : @@ -2333,6 +2356,7 @@ view = .zoom-extents = Zoom Umfang + .cmd-no-hatch-objects = __ocs_fmt_0__: keine Schraffurobjekte. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: keine Text- oder Dimensionsobjekte. .cmd-not-yet-implemented = __ocs_fmt_0__: noch nicht umgesetzt. .viewport-s-in-layout = __ocs_fmt_0__ Ansichtsfenster(s) im Layout "__ocs_fmt_1__": @@ -2710,6 +2734,9 @@ properties = .plot-style-table = Plotstiltabelle .plot-table-attached-to = Angefügte Parzellentabelle .plot-table-type = Art der Flächentabelle + .color-dependent-plot-styles = Farbabhängige Plotstile + .named-plot-styles = Benannte Plotstile + .plot-style-is-locked-to-color-in-color-dependent = Im farbabhängigen Modus (CTB) ist der Plotstil an die Farbe gebunden .point-1-x = Punkt 1 X .point-1-y = Punkt 1 Y .point-1-z = Punkt 1 Z @@ -3002,7 +3029,7 @@ styles = .dim-line-weight-dimlwd = Dim Line Gewicht (DIMLWD) .dimstyle-updated = DimStyle '__ocs_fmt_0__' aktualisiert. .dimension-line = Bemaßungslinie - .dimension-style-manager = Bemaßungsstil-Manager + .dimension-style-manager = Bemaßungsstil-Verwaltung .double-click-a-scale-to-rename-it-edit-its-pap = Zum Umbenennen auf einen Maßstab doppelklicken. Hier wird das Verhältnis zwischen Papier- und Zeichnungseinheiten bearbeitet. Neu oder Kopieren fügt einen Maßstab hinzu. Änderungen werden erst nach Anwenden gespeichert. .down = nach unten .drawing-background = Zeichnungshintergrund @@ -3058,8 +3085,8 @@ styles = .middleoftext = Mitte des Textes .middleoftopline = Mitte der oberen Linie .minus-tolerance-dimtm = Minustoleranz (DIMTM) - .multileader-style-manager = Multiführungslinienstil-Manager - .multiline-style-manager = Mehrfachlinienstil-Manager + .multileader-style-manager = Multiführungslinienstil-Verwaltung + .multiline-style-manager = Mehrfachlinienstil-Verwaltung .no-linetypes-defined = Keine Linientypen definiert. .not-stacked = nicht gestapelt .overkill-deleted-n-duplicate-object-s = OVERKILL: gelöscht __ocs_fmt_0__ doppeltes Objekt(e). @@ -3119,7 +3146,7 @@ styles = .styles-title-case = Stile .texteditmode-v = TEXTEDITMODE = __ocs_fmt_0__ .texteditmode-set-to-n = TEXTEDITMODE auf __ocs_fmt_0__ gesetzt - .table-style-manager = Tabellenstil-Manager + .table-style-manager = Tabellenstil-Verwaltung .text-style-manager = Textstilverwaltung .text-style-label-sentence-case = Textstil: .topoftopline = Obere Linie diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 671366f4..03332480 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Draw .annotate = Annotate .insert = Insert - .model = Model + .model = Modelling .layout = Layout .manage = Manage .view = View @@ -333,6 +333,7 @@ common = .brightness = Brightness .by-layer = By layer .byblock = ByBlock + .bycolor = ByColor .bylayer = ByLayer .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: converted __ocs_fmt_0__ solid(s) to surface(s). .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: select a solid created this session. @@ -1271,6 +1272,8 @@ draw = .donut-specify-center-of-donut-enter-to-exit = DONUT Specify center of donut (Enter to exit): .donut-specify-inside-diameter-0 = DONUT Specify inside diameter <0>: .donut-specify-outside-diameter = DONUT Specify outside diameter: + .donut-specify-second-point-for-inside-diameter = DONUT Specify second point for inside diameter: + .donut-specify-second-point-for-outside-diameter = DONUT Specify second point for outside diameter: .draw-manually = Draw manually .ellipse-specify-center = ELLIPSE Specify center: .ellipse-specify-major-axis-endpoint = ELLIPSE Specify major axis endpoint: @@ -1827,14 +1830,24 @@ annotate = .collect-leaders = Collect Leaders .ddedit-select-text-entity = DDEDIT Select text entity: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED Enter dimension text (blank = measured value): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Specify dimension line location [Text/Angle]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED Specify first extension line origin: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED Specify second extension line origin [Text/Angle]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED Enter formatted dimension text (blank = measured value): + .dimaligned-select-object-to-dimension = DIMALIGNED Select object to dimension: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Specify dimension line location [Mtext/Text/Angle]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED Specify first extension line origin or press Enter to select object: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED Specify second extension line origin: .dimaligned-specify-text-angle-degrees = DIMALIGNED Specify text angle (degrees): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR Enter dimension text (blank = measured value): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR Enter formatted dimension text (blank = measured value): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR Select arc, circle, line, or specify an angle vertex: + .dimangular-select-second-line = DIMANGULAR Select second line: .dimangular-specify-angle-vertex = DIMANGULAR Specify angle vertex: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR Specify dimension arc location [Mtext/Text/Angle/Quadrant]: .dimangular-specify-dimension-arc-location = DIMANGULAR Specify dimension arc location: .dimangular-specify-first-extension-line-point = DIMANGULAR Specify first extension line point: + .dimangular-specify-quadrant = DIMANGULAR Specify quadrant: .dimangular-specify-second-extension-line-point = DIMANGULAR Specify second extension line point: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR Specify second point for text angle: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR Specify text angle or first point: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE No base dimension found. Place a dimension first. .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE Specify a second extension line origin (Enter to exit): .dimbreak-select-dimension-to-break = DIMBREAK Select dimension to break: @@ -1842,8 +1855,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE No base dimension found. Place a dimension first. .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE Specify a second extension line origin (Enter to exit): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER Enter dimension text (blank = measured value): - .dimdiameter-specify-center-point = DIMDIAMETER Specify center point: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER Specify dimension line location [Text/Angle]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER Select arc, circle, or polyline arc: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER Specify dimension line location [Mtext/Text/Angle]: .dimdiameter-specify-point-on-circle = DIMDIAMETER Specify point on circle: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER Specify text angle (degrees): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT Enter text override (blank = reset to measured): @@ -1851,6 +1864,12 @@ annotate = .dimjogged-created-a-jogged-radius-dimension = DIMJOGGED: created a jogged radius dimension. .dimjogged-invalid-radius = DIMJOGGED: invalid radius. .dimjogged-select-an-arc-or-circle-first = DIMJOGGED: select an arc or circle first. + .dimjogged-enter-dimension-text-blank-measured = DIMJOGGED Enter dimension text (blank = measured value): + .dimjogged-select-arc-circle-or-polyline-arc = DIMJOGGED Select arc, circle, or polyline arc: + .dimjogged-specify-center-location-override = DIMJOGGED Specify center location override: + .dimjogged-specify-dimension-line-location-text = DIMJOGGED Specify dimension line location [Mtext/Text/Angle]: + .dimjogged-specify-jog-location = DIMJOGGED Specify jog location: + .dimjogged-specify-text-angle-degrees = DIMJOGGED Specify text angle (degrees): .dimjogline-select-linear-or-aligned-dimension = DIMJOGLINE Select linear or aligned dimension: .dimjogline-specify-jog-location = DIMJOGLINE Specify jog location: .dimlinear-enter-dimension-text-blank-measured = DIMLINEAR Enter dimension text (blank = measured value): @@ -1859,10 +1878,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR Specify second extension line origin [Text/Angle]: .dimlinear-specify-text-angle-degrees = DIMLINEAR Specify text angle (degrees): .dimordinate-specify-feature-location = DIMORDINATE Specify feature location: - .dimordinate-specify-leader-endpoint = DIMORDINATE Specify leader endpoint: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE Enter dimension text (blank = measured value): + .dimordinate-specify-leader-endpoint = DIMORDINATE Specify leader endpoint [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE Specify text angle (degrees): .dimradius-enter-dimension-text-blank-measured = DIMRADIUS Enter dimension text (blank = measured value): - .dimradius-specify-center-point = DIMRADIUS Specify center point: - .dimradius-specify-dimension-line-location-text = DIMRADIUS Specify dimension line location [Text/Angle]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Select arc, circle, or polyline arc: + .dimradius-specify-dimension-line-location-text = DIMRADIUS Specify dimension line location [Mtext/Text/Angle]: .dimradius-specify-radius-point = DIMRADIUS Specify radius point: .dimradius-specify-text-angle-degrees = DIMRADIUS Specify text angle (degrees): .dimspace-enter-value-0-auto = DIMSPACE Enter value (0 = auto): @@ -1894,6 +1915,7 @@ annotate = Data .lower = Lower .mleader-specify-arrowhead-point = MLEADER Specify arrowhead point: + .mleader-specify-landing-point = MLEADER Specify landing point: .mleader-specify-next-point-count-pts-enter-to = MLEADER Specify next point [__ocs_arg_count__ pts — Enter to place text]: .mleaderadd-select-a-multileader = MLEADERADD Select a multileader: .mleaderadd-specify-arrowhead-location = MLEADERADD Specify arrowhead location: @@ -1927,8 +1949,13 @@ annotate = .scale-list = Scale List .sentence = Sentence .sync-scales = Sync Scales + .table-cell-enter-text-row-column-current = TABLE CELL Enter text for [__ocs_arg_row__,__ocs_arg_column__] <__ocs_arg_current__>: + .table-enter-column-width-width = TABLE Enter column width [__ocs_arg_width__]: .table-enter-number-of-columns-cols = TABLE Enter number of columns [__ocs_arg_cols__]: + .table-enter-number-of-data-rows-rows-cols = TABLE Enter number of data rows [__ocs_arg_rows__] (__ocs_arg_cols__ cols): .table-enter-number-of-rows-incl-header-rows-co = TABLE Enter number of rows (incl. header) [__ocs_arg_rows__] (__ocs_arg_cols__ cols): + .table-enter-row-height-height = TABLE Enter row height [__ocs_arg_height__]: + .table-insertion-behavior-point-window-mode = TABLE Insertion behavior [Point/Window] <__ocs_arg_mode__>: .table-specify-insertion-point-cols-rows = TABLE Specify insertion point [__ocs_arg_cols__×__ocs_arg_rows__]: .tcase-upper-lower-sentence-title = TCASE [Upper / Lower / Sentence / Title]: .tcase-select-text-objects-first = TCASE: select text objects first. @@ -2063,6 +2090,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ object(s) cut to clipboard. model = + .create = Create + .boolean = Boolean + .pyramid = Pyramid + .polysolid = Polysolid + .extrude = Extrude + .revolve = Revolve + .loft = Loft + .sweep = Sweep + .presspull = Presspull .n-specify-center-point = __ocs_arg_n__ Specify center point: .n-specify-first-corner = __ocs_arg_n__ Specify first corner: .n-specify-height-enter-for-default = __ocs_arg_n__ Specify height : @@ -2206,6 +2242,7 @@ view = .color-scheme-set-to-name = Color scheme set to '__ocs_fmt_0__'. .current-annotation-scale-name = Current annotation scale: __ocs_fmt_0__ .datalink-path-to-the-csv-file = DATALINK path to the .csv file: + .datalink-specify-insertion-point = DATALINK Specify insertion point: .datalink-cannot-read-path-e = DATALINK: cannot read "__ocs_fmt_0__": __ocs_fmt_1__ .datalink-imported-nrows-ncols-cells-into-a-tab = DATALINK: imported __ocs_fmt_0__×__ocs_fmt_1__ cells into a table at the origin. .datalink-the-csv-file-is-empty = DATALINK: the CSV file is empty. @@ -2280,6 +2317,11 @@ view = .shortcut-set-key-cmd-str = Shortcut set: __ocs_fmt_0__ → __ocs_fmt_1__ .table-cell-select-a-table-entity-first-or-row = TABLE CELL: select a Table entity first, or row/col out of range. .table-cell-set-row-col-text = TABLE CELL: set [__ocs_fmt_0__,__ocs_fmt_1__] = "__ocs_fmt_2__". + .table-block-definition-not-found = TABLE: block definition not found. + .table-edit-applied = TABLE: edit applied. + .table-field-handle-not-found = TABLE: field handle not found. + .table-row-column-out-of-range-or-cell-locked = TABLE: row/column is out of range or the cell is locked. + .table-select-a-table-first = TABLE: select a table first. .toolpalettes-tool-palettes-not-yet-implemented = TOOLPALETTES: Tool Palettes not yet implemented. .tile-horiz = Tile @@ -2322,6 +2364,7 @@ view = .usage-shortcuts-list-set-key-cmd-clear-key = Usage: SHORTCUTS LIST | SET | CLEAR .usage-shortcuts-set-key-command-e-g-shortcuts = Usage: SHORTCUTS SET e.g. SHORTCUTS SET CTRL+D DIST .usage-table-cell-row-col-text = Usage: TABLE CELL + .usage-table-edit-actions = Usage: TABLE CELL | INSERTROW | DELETEROW | INSERTCOL | DELETECOL | MERGE | UNMERGE | ROWHEIGHT | COLWIDTH | LOCK | BLOCK | FORMULA | FIELD .usage-ucsicon-on-off-noorigin-origin = Usage: UCSICON ON | OFF | NOORIGIN | ORIGIN .usage-underlay-fade-n-contrast-n-on-off-clip-o = Usage: UNDERLAY [FADE |CONTRAST |ON|OFF|CLIP ON|OFF|MONO ON|OFF] .usage-useri-1-5-int-userr-1-5-real = Usage: USERI <1-5> | USERR <1-5> @@ -2363,6 +2406,7 @@ view = .zoom-extents = Zoom Extents + .cmd-no-hatch-objects = __ocs_fmt_0__: no hatch objects. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: no text or dimension objects. .cmd-not-yet-implemented = __ocs_fmt_0__: not yet implemented. .viewport-s-in-layout = __ocs_fmt_0__ viewport(s) in layout "__ocs_fmt_1__": @@ -2563,6 +2607,7 @@ properties = .ext-line-lineweight = Ext line lineweight .ext-line-offset = Ext line offset .extended-entity = Extended Entity + .extended-data = Extended Data .extended-lighting = Extended Lighting .extents-max = Extents Max .extents-min = Extents Min @@ -2628,6 +2673,7 @@ properties = .island-detection-style = Island detection style .italic = Italic .jog-angle = Jog Angle + .jogged-radius = Jogged Radius .jog-x = Jog X .jog-y = Jog Y .jog-z = Jog Z @@ -2758,6 +2804,9 @@ properties = .plot-style-table = Plot style table .plot-table-attached-to = Plot table attached to .plot-table-type = Plot table type + .color-dependent-plot-styles = Color-dependent plot styles + .named-plot-styles = Named plot styles + .plot-style-is-locked-to-color-in-color-dependent = Plot style is locked to color in Color-Dependent (CTB) mode .point-1-x = Point 1 X .point-1-y = Point 1 Y .point-1-z = Point 1 Z diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index cf6101bd..e158ab8f 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Dibujo .annotate = Anotar .insert = Insertar - .model = Modelo + .model = Modelado .layout = Presentación .manage = Administrar .view = Vista @@ -333,6 +333,7 @@ common = .brightness = Brillo .by-layer = Por capa .byblock = PorBloque + .bycolor = PorColor .bylayer = PorCapa .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: se convirtieron __ocs_fmt_0__ sólidos en superficies. .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: seleccione un sólido creado en esta sesión. @@ -1799,14 +1800,24 @@ annotate = .collect-leaders = Recopilar directrices .ddedit-select-text-entity = DDEDIT Seleccione una entidad de texto: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED Introduzca el texto de cota (vacío = valor medido): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Especifique la ubicación de la línea de cota [Texto/Ángulo]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED Especifique el origen de la primera línea de referencia: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED Especifique el origen de la segunda línea de referencia [Texto/Ángulo]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED Introduzca el texto de cota con formato (vacío = valor medido): + .dimaligned-select-object-to-dimension = DIMALIGNED Seleccione el objeto que desea acotar: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Especifique la ubicación de la línea de cota [Mtext/Texto/Ángulo]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED Especifique el origen de la primera línea de referencia o pulse Enter para seleccionar un objeto: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED Especifique el origen de la segunda línea de referencia: .dimaligned-specify-text-angle-degrees = DIMALIGNED Especifique el ángulo del texto (grados): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR Introduzca el texto de cota (vacío = valor medido): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR Introduzca el texto de cota con formato (vacío = valor medido): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR Seleccione arco, círculo, línea o especifique un vértice de ángulo: + .dimangular-select-second-line = DIMANGULAR Seleccione la segunda línea: .dimangular-specify-angle-vertex = DIMANGULAR Especifique el vértice del ángulo: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR Especifique la ubicación del arco de cota [Mtext/Texto/Ángulo/Cuadrante]: .dimangular-specify-dimension-arc-location = DIMANGULAR Especifique la ubicación del arco de cota: .dimangular-specify-first-extension-line-point = DIMANGULAR Especifique un punto de la primera línea de referencia: + .dimangular-specify-quadrant = DIMANGULAR Especifique el cuadrante: .dimangular-specify-second-extension-line-point = DIMANGULAR Especifique un punto de la segunda línea de referencia: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR Especifique el segundo punto para el ángulo del texto: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR Especifique el ángulo del texto o el primer punto: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE No se encontró ninguna cota base. Coloque primero una cota. .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE Especifique el origen de una segunda línea de referencia (Enter para salir): .dimbreak-select-dimension-to-break = DIMBREAK Seleccione la cota que desea partir: @@ -1814,8 +1825,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE No se encontró ninguna cota base. Coloque primero una cota. .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE Especifique el origen de una segunda línea de referencia (Enter para salir): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER Introduzca el texto de cota (vacío = valor medido): - .dimdiameter-specify-center-point = DIMDIAMETER Especifique el centro: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER Especifique la ubicación de la línea de cota [Texto/Ángulo]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER Seleccione un arco, círculo o arco de polilínea: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER Especifique la ubicación de la línea de cota [Mtexto/Texto/Ángulo]: .dimdiameter-specify-point-on-circle = DIMDIAMETER Especifique un punto del círculo: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER Especifique el ángulo del texto (grados): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT Introduzca la sustitución de texto (vacío = restablecer al valor medido): @@ -1831,10 +1842,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR Especifique el origen de la segunda línea de referencia [Texto/Ángulo]: .dimlinear-specify-text-angle-degrees = DIMLINEAR Especifique el ángulo del texto (grados): .dimordinate-specify-feature-location = DIMORDINATE Especifique la ubicación del elemento: - .dimordinate-specify-leader-endpoint = DIMORDINATE Especifique el punto final de la directriz: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE Introduzca el texto de cota (vacío = valor medido): + .dimordinate-specify-leader-endpoint = DIMORDINATE Especifique el punto final de la directriz [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE Especifique el ángulo del texto (grados): .dimradius-enter-dimension-text-blank-measured = DIMRADIUS Introduzca el texto de cota (vacío = valor medido): - .dimradius-specify-center-point = DIMRADIUS Especifique el centro: - .dimradius-specify-dimension-line-location-text = DIMRADIUS Especifique la ubicación de la línea de cota [Texto/Ángulo]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Seleccione un arco, círculo o arco de polilínea: + .dimradius-specify-dimension-line-location-text = DIMRADIUS Especifique la ubicación de la línea de cota [Mtexto/Texto/Ángulo]: .dimradius-specify-radius-point = DIMRADIUS Especifique el punto del radio: .dimradius-specify-text-angle-degrees = DIMRADIUS Especifique el ángulo del texto (grados): .dimspace-enter-value-0-auto = DIMSPACE Introduzca el valor (0 = automático): @@ -1866,6 +1879,7 @@ annotate = datos .lower = Inferior .mleader-specify-arrowhead-point = MLEADER Especifique el punto de la punta de flecha: + .mleader-specify-landing-point = MLEADER Especifique el punto de conexión: .mleader-specify-next-point-count-pts-enter-to = MLEADER Especifique el punto siguiente [__ocs_arg_count__ puntos; Enter para colocar texto]: .mleaderadd-select-a-multileader = MLEADERADD Seleccione una directriz múltiple: .mleaderadd-specify-arrowhead-location = MLEADERADD Especifique la ubicación de la punta de flecha: @@ -2035,6 +2049,15 @@ insert = .object-s-cut-to-clipboard = Se cortaron __ocs_fmt_0__ objetos al portapapeles. model = + .create = Crear + .boolean = Booleanas + .pyramid = Pirámide + .polysolid = Polisólido + .extrude = Extruir + .revolve = Revolucionar + .loft = Transición + .sweep = Barrido + .presspull = Presionar/tirar .n-specify-center-point = __ocs_arg_n__ Especifique el centro: .n-specify-first-corner = __ocs_arg_n__ Especifique la primera esquina: .n-specify-height-enter-for-default = __ocs_arg_n__ Especifique la altura : @@ -2335,6 +2358,7 @@ view = .zoom-extents = Zoom Extensión + .cmd-no-hatch-objects = __ocs_fmt_0__: no hay objetos de sombreado. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: no hay objetos de texto ni de cota. .cmd-not-yet-implemented = __ocs_fmt_0__: aún no implementado. .viewport-s-in-layout = __ocs_fmt_0__ ventanas gráficas en la presentación «__ocs_fmt_1__»: @@ -2712,6 +2736,9 @@ properties = .plot-style-table = Tabla de estilos de trazado .plot-table-attached-to = Tabla de trazado asociada a .plot-table-type = Tipo de tabla de trazado + .color-dependent-plot-styles = Estilos de trazado dependientes del color + .named-plot-styles = Estilos de trazado con nombre + .plot-style-is-locked-to-color-in-color-dependent = En el modo dependiente del color (CTB), el estilo de trazado está vinculado al color .point-1-x = Punto 1 X .point-1-y = Punto 1 Y .point-1-z = Punto 1 Z diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 01752032..7308d1b8 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Piirrä .annotate = Merkinnät .insert = Lisää - .model = Malli + .model = Mallinnus .layout = Asettelu .manage = Hallinta .view = Näkymä @@ -332,6 +332,7 @@ common = .brightness = Kirkkaus .by-layer = Kerroksen mukaan .byblock = ByBlock + .bycolor = Värin mukaan .bylayer = Layerin mukaan .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: muutettu __ocs_fmt_0__-kiintoaine(t) pinnoiksi. .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: valitse tässä istunnossa luotu solid. @@ -1798,14 +1799,24 @@ annotate = .collect-leaders = Kerää johtajia .ddedit-select-text-entity = DDEDIT Valitse tekstikokonaisuus: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED Enter mittateksti (tyhjä = mitattu arvo): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Määritä mittaviivan sijainti [teksti/kulma]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED Määritä ensimmäisen jatkolinjan alkuperä: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED Määritä toisen jatkolinjan alkuperä [teksti/kulma]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED Syötä muotoiltu mittateksti (tyhjä = mitattu arvo): + .dimaligned-select-object-to-dimension = DIMALIGNED Valitse mitoitettava objekti: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Määritä mittaviivan sijainti [Mtext/Teksti/Kulma]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED Määritä ensimmäisen apuviivan alkupiste tai valitse objekti painamalla Enter: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED Määritä toisen apuviivan alkupiste: .dimaligned-specify-text-angle-degrees = DIMALIGNED Määritä tekstin kulma (asteita): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR Syötä mittateksti (tyhjä = mitattu arvo): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR Syötä muotoiltu mittateksti (tyhjä = mitattu arvo): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR Valitse kaari, ympyrä tai viiva tai määritä kulman kärkipiste: + .dimangular-select-second-line = DIMANGULAR Valitse toinen viiva: .dimangular-specify-angle-vertex = DIMANGULAR Määritä kulman kärkipiste: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR Määritä mittakaaren sijainti [Mtext/Teksti/Kulma/Neljännes]: .dimangular-specify-dimension-arc-location = DIMANGULAR Määritä mittakaaren sijainti: .dimangular-specify-first-extension-line-point = DIMANGULAR Määritä ensimmäinen jatkolinjan piste: + .dimangular-specify-quadrant = DIMANGULAR Määritä neljännes: .dimangular-specify-second-extension-line-point = DIMANGULAR Määritä toinen jatkolinjan piste: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR Määritä tekstikulman toinen piste: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR Määritä tekstikulma tai ensimmäinen piste: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE Perusmittaa ei löytynyt. Aseta ensin mitta. .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE Määritä toinen alalinjan lähtökohta (Enter poistuaksesi): .dimbreak-select-dimension-to-break = DIMBREAK Valitse katkaistava mitta: @@ -1813,8 +1824,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE Perusmittaa ei löytynyt. Aseta ensin mitta. .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE Määritä toinen alalinjan lähtökohta (Enter poistuaksesi): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER Enter mittateksti (tyhjä = mitattu arvo): - .dimdiameter-specify-center-point = DIMDIAMETER Määritä keskipiste: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER Määritä mittaviivan sijainti [teksti/kulma]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER Valitse kaari, ympyrä tai murtoviivan kaari: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER Määritä mittaviivan sijainti [Mteksti/teksti/kulma]: .dimdiameter-specify-point-on-circle = DIMDIAMETER Määritä piste ympyrässä: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER Määritä tekstin kulma (asteita): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT Enter tekstin ohitus (tyhjä = nollattu mitattuun): @@ -1830,10 +1841,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR Määritä toisen jatkolinjan alkuperä [teksti/kulma]: .dimlinear-specify-text-angle-degrees = DIMLINEAR Määritä tekstin kulma (asteita): .dimordinate-specify-feature-location = DIMORDINATE Määritä ominaisuuden sijainti: - .dimordinate-specify-leader-endpoint = DIMORDINATE Määritä johtajan päätepiste: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE Syötä mittateksti (tyhjä = mitattu arvo): + .dimordinate-specify-leader-endpoint = DIMORDINATE Määritä johtajan päätepiste [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE Määritä tekstin kulma (asteita): .dimradius-enter-dimension-text-blank-measured = DIMRADIUS Enter mittateksti (tyhjä = mitattu arvo): - .dimradius-specify-center-point = DIMRADIUS Määritä keskipiste: - .dimradius-specify-dimension-line-location-text = DIMRADIUS Määritä mittaviivan sijainti [teksti/kulma]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Valitse kaari, ympyrä tai murtoviivan kaari: + .dimradius-specify-dimension-line-location-text = DIMRADIUS Määritä mittaviivan sijainti [Mteksti/teksti/kulma]: .dimradius-specify-radius-point = DIMRADIUS Määritä sädepiste: .dimradius-specify-text-angle-degrees = DIMRADIUS Määritä tekstin kulma (asteita): .dimspace-enter-value-0-auto = DIMSPACE Enter-arvo (0 = automaattinen): @@ -1865,6 +1878,7 @@ annotate = Data .lower = Alempi .mleader-specify-arrowhead-point = MLEADER Määritä nuolenpää: + .mleader-specify-landing-point = MLEADER Määritä vaakaviivan piste: .mleader-specify-next-point-count-pts-enter-to = MLEADER Määritä seuraava piste [__ocs_arg_count__ pts — Enter tekstin sijoittamiseksi]: .mleaderadd-select-a-multileader = MLEADERADD Valitse monijohtaja: .mleaderadd-specify-arrowhead-location =MLEADERADD Määritä nuolenpään sijainti: @@ -2034,6 +2048,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ objekti(t) leikattu leikepöydälle. model = + .create = Luo + .boolean = Boolen operaatiot + .pyramid = Pyramidi + .polysolid = Monikappale + .extrude = Pursota + .revolve = Pyöräytä + .loft = Loftaus + .sweep = Pyyhkäisy + .presspull = Työnnä/vedä .n-specify-center-point = __ocs_arg_n__ Määritä keskipiste: .n-specify-first-corner = __ocs_arg_n__ Määritä ensimmäinen kulma: .n-specify-height-enter-for-default = __ocs_arg_n__ Määritä korkeus : @@ -2334,6 +2357,7 @@ view = .zoom-extents = Zoomaa Laajuudet + .cmd-no-hatch-objects = __ocs_fmt_0__: ei t├ñytt├Âobjekteja. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: ei tekstiä tai mittaobjekteja. .cmd-not-yet-implemented = __ocs_fmt_0__: ei vielä otettu käyttöön. .viewport-s-in-layout =__ocs_fmt_0__-näkymä(t) asettelussa "__ocs_fmt_1__": @@ -2711,6 +2735,9 @@ properties = .plot-style-table = Juontyylinen pöytä .plot-table-attached-to =Tonttitaulukko liitteenä .plot-table-type = Piirretaulukon tyyppi + .color-dependent-plot-styles = Väristä riippuvat tulostustyylit + .named-plot-styles = Nimetyt tulostustyylit + .plot-style-is-locked-to-color-in-color-dependent = Väristä riippuvassa tilassa (CTB) tulostustyyli määräytyy värin mukaan .point-1-x = Kohta 1 X .point-1-y = Kohta 1 Y .point-1-z = Kohta 1 Z diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 8270c93d..00182b36 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Dessin .annotate = Annoter .insert = Insérer - .model = Modèle + .model = Modélisation .layout = Mise en page .manage = Gérer .view = Affichage @@ -333,6 +333,7 @@ common = .brightness = Luminosité .by-layer = DuCalque .byblock = Par bloc + .bycolor = Par couleur .bylayer = ParLayer .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: converti en surface ou en surface __ocs_fmt_0__ solide(s). .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE : sélectionnez une session solide créée. @@ -1799,14 +1800,24 @@ annotate = .collect-leaders = Regrouper les repères .ddedit-select-text-entity = DDEDIT Sélectionnez l’objet texte : .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED Entrer le texte dimensionnel (blanc = valeur mesurée): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Spécifiez la position de la ligne de cote [Texte / Angle] : - .dimaligned-specify-first-extension-line-origin = DIMALIGNED Spécifiez l'origine de la première ligne d'extension: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED Spécifiez l'origine de la deuxième ligne d'extension [Texte/Angle]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED Saisissez le texte de cote formaté (vide = valeur mesurée) : + .dimaligned-select-object-to-dimension = DIMALIGNED Sélectionnez l'objet à coter : + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Spécifiez la position de la ligne de cote [Mtext/Texte/Angle] : + .dimaligned-specify-first-extension-line-origin = DIMALIGNED Spécifiez l'origine de la première ligne d'attache ou appuyez sur Entrée pour sélectionner un objet : + .dimaligned-specify-second-extension-line-origi = DIMALIGNED Spécifiez l'origine de la deuxième ligne d'attache : .dimaligned-specify-text-angle-degrees = DIMALIGNED Spécifiez l'angle de texte (degrés): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR Saisissez le texte de cote (vide = valeur mesurée) : + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR Saisissez le texte de cote formaté (vide = valeur mesurée) : + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR Sélectionnez un arc, un cercle ou une ligne, ou spécifiez un sommet d'angle : + .dimangular-select-second-line = DIMANGULAR Sélectionnez la deuxième ligne : .dimangular-specify-angle-vertex = DIMANGULAR Spécifiez le sommet d'angle: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR Spécifiez l'emplacement de l'arc de cote [Mtext/Texte/Angle/Quadrant] : .dimangular-specify-dimension-arc-location = DIMANGULAR Spécifier l'emplacement de l'arc dimensionnel: .dimangular-specify-first-extension-line-point = DIMANGULAR Spécifiez le premier point de ligne d'extension: + .dimangular-specify-quadrant = DIMANGULAR Spécifiez le quadrant : .dimangular-specify-second-extension-line-point = DIMANGULAR Spécifiez le deuxième point de la ligne d'extension: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR Spécifiez le deuxième point pour l'angle du texte : + .dimangular-specify-text-angle-or-first-point = DIMANGULAR Spécifiez l'angle du texte ou le premier point : .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE Aucune cote de base trouvée. Créez d’abord une cote. .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE Spécifiez une deuxième ligne d'extension d'origine (Entrer pour sortir): .dimbreak-select-dimension-to-break = DIMBREAK Sélectionnez la dimension à casser: @@ -1814,8 +1825,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE Aucune cote de base trouvée. Créez d’abord une cote. .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE Spécifiez une deuxième ligne d'extension d'origine (Entrer pour sortir): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER Entrer le texte dimensionnel (blanc = valeur mesurée): - .dimdiameter-specify-center-point = DIMDIAMETER Spécifier le point central: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER Spécifiez la position de la ligne de cote [Texte / Angle] : + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER Sélectionnez un arc, un cercle ou un arc de polyligne : + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER Spécifiez la position de la ligne de cote [Mtexte/Texte/Angle] : .dimdiameter-specify-point-on-circle = DIMDIAMETER Spécifier le point sur le cercle: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER Spécifiez l'angle de texte (degrés): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT Saisissez la valeur de la valeur de remplacement (blank = reset to meased): @@ -1831,10 +1842,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR Spécifiez l'origine de la deuxième ligne d'extension [Texte/Angle]: .dimlinear-specify-text-angle-degrees = DIMLINEAR Spécifiez l'angle de texte (degrés): .dimordinate-specify-feature-location = DIMORDINATE Spécifiez le point à coter : - .dimordinate-specify-leader-endpoint = DIMORDINATE Spécifiez l’extrémité du repère : + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE Entrez le texte de cote (vide = valeur mesurée) : + .dimordinate-specify-leader-endpoint = DIMORDINATE Spécifiez l’extrémité du repère [Xdatum/Ydatum/Mtext/Text/Angle] : + .dimordinate-specify-text-angle-degrees = DIMORDINATE Spécifiez l'angle du texte (degrés) : .dimradius-enter-dimension-text-blank-measured = DIMRADIUS Entrer le texte dimensionnel (blanc = valeur mesurée): - .dimradius-specify-center-point = DIMRADIUS Spécifier le point central: - .dimradius-specify-dimension-line-location-text = DIMRADIUS Spécifiez la position de la ligne de cote [Texte / Angle] : + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Sélectionnez un arc, un cercle ou un arc de polyligne : + .dimradius-specify-dimension-line-location-text = DIMRADIUS Spécifiez la position de la ligne de cote [Mtexte/Texte/Angle] : .dimradius-specify-radius-point = DIMRADIUS Spécifier le point de rayon: .dimradius-specify-text-angle-degrees = DIMRADIUS Spécifiez l'angle de texte (degrés): .dimspace-enter-value-0-auto = DIMSPACE Entrer la valeur (0 = automatique) : @@ -1866,6 +1879,7 @@ annotate = Données .lower = Moins .mleader-specify-arrowhead-point = MLEADER Spécifiez le point de la tête de flèche: + .mleader-specify-landing-point = MLEADER Spécifiez le point de palier: .mleader-specify-next-point-count-pts-enter-to = MLEADER Spécifiez le point suivant [__ocs_arg_count__ points — Entrée pour placer le texte] : .mleaderadd-select-a-multileader = MLEADERADD Sélectionnez une ligne de repère multiple : .mleaderadd-specify-arrowhead-location = MLEADERADD Spécifiez l'emplacement de la tête de flèche: @@ -2035,6 +2049,15 @@ insert = .object-s-cut-to-clipboard = Objets coupés vers le presse-papiers : __ocs_fmt_0__. model = + .create = Créer + .boolean = Opérations booléennes + .pyramid = Pyramide + .polysolid = Polysolide + .extrude = Extruder + .revolve = Révolution + .loft = Lissage + .sweep = Balayage + .presspull = Tirer-pousser .n-specify-center-point = __ocs_arg_n__ Spécifier le point central: .n-specify-first-corner = __ocs_arg_n__ Spécifiez le premier coin : .n-specify-height-enter-for-default = __ocs_arg_n__ Spécifiez la hauteur : @@ -2335,6 +2358,7 @@ view = .zoom-extents = Zoom Portée + .cmd-no-hatch-objects = __ocs_fmt_0__: aucun objet de hachure. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: aucun objet texte ou dimensionnel. .cmd-not-yet-implemented = __ocs_fmt_0__: pas encore mis en œuvre. .viewport-s-in-layout = __ocs_fmt_0__ fenêtre(s) dans la présentation « __ocs_fmt_1__ » : @@ -2712,6 +2736,9 @@ properties = .plot-style-table = Table des styles de tracé .plot-table-attached-to = Tableau de correspondance joint à la .plot-table-type = Type de table de tracé + .color-dependent-plot-styles = Styles de tracé dépendant de la couleur + .named-plot-styles = Styles de tracé nommés + .plot-style-is-locked-to-color-in-color-dependent = En mode dépendant de la couleur (CTB), le style de tracé est lié à la couleur .point-1-x = Point 1 X .point-1-y = Point 1 Y .point-1-z = Point 1 Z diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index abf200ab..30acc142 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = ड्रॉ .annotate = एनोटेट .insert = सम्मिलित करें - .model = मॉडल + .model = मॉडलिंग .layout = लेआउट .manage = प्रबंधित करें .view = देखें @@ -321,6 +321,7 @@ common = .brightness = चमक .by-layer = ByLayer .byblock = ब्लॉक द्वारा + .bycolor = रंग द्वारा .bylayer = ByLayer .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: सतह (s) के लिए __ocs_fmt_0__ ठोस (s) परिवर्तित। .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: इस सत्र को एक ठोस बनाया गया चयन करें। @@ -1787,14 +1788,24 @@ annotate = .collect-leaders = लीडर एकत्र करें .ddedit-select-text-entity = DDEDIT टेक्स्ट ऑब्जेक्ट चुनें: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED आयाम टेक्स्ट दर्ज करें (blank = मापा मान): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED आयाम लाइन स्थान निर्दिष्ट करें [टेक्स्ट / एंगल]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED पहली एक्सटेंशन लाइन उत्पत्ति निर्दिष्ट करें: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED दूसरी एक्सटेंशन लाइन उत्पत्ति निर्दिष्ट करें [टेक्स्ट / एंगल]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED स्वरूपित आयाम टेक्स्ट दर्ज करें (खाली = मापा मान): + .dimaligned-select-object-to-dimension = DIMALIGNED आयाम देने के लिए ऑब्जेक्ट चुनें: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED आयाम रेखा का स्थान निर्दिष्ट करें [Mtext/टेक्स्ट/कोण]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED पहली एक्सटेंशन रेखा का मूल निर्दिष्ट करें या ऑब्जेक्ट चुनने के लिए Enter दबाएँ: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED दूसरी एक्सटेंशन रेखा का मूल निर्दिष्ट करें: .dimaligned-specify-text-angle-degrees = DIMALIGNED पाठ कोण निर्दिष्ट करें (डिग्री): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR आयाम टेक्स्ट दर्ज करें (खाली = मापा मान): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR स्वरूपित आयाम टेक्स्ट दर्ज करें (खाली = मापा मान): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR चाप, वृत्त या रेखा चुनें, या कोण का शीर्ष निर्दिष्ट करें: + .dimangular-select-second-line = DIMANGULAR दूसरी रेखा चुनें: .dimangular-specify-angle-vertex = DIMANGULAR कोण वर्टेक्स निर्दिष्ट करें: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR आयाम आर्क स्थान निर्दिष्ट करें [Mtext/टेक्स्ट/कोण/चतुर्थांश]: .dimangular-specify-dimension-arc-location = DIMANGULAR आयाम आर्क स्थान निर्दिष्ट करें: .dimangular-specify-first-extension-line-point = DIMANGULAR पहली एक्सटेंशन लाइन बिंदु निर्दिष्ट करें: + .dimangular-specify-quadrant = DIMANGULAR चतुर्थांश निर्दिष्ट करें: .dimangular-specify-second-extension-line-point = DIMANGULAR दूसरी एक्सटेंशन लाइन बिंदु निर्दिष्ट करें: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR टेक्स्ट कोण के लिए दूसरा बिंदु निर्दिष्ट करें: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR टेक्स्ट कोण या पहला बिंदु निर्दिष्ट करें: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE कोई आधार आयाम नहीं मिला। पहले आयाम दें। .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE एक दूसरी विस्तार लाइन मूल निर्दिष्ट करें (बाहर निकलने के लिए बेहतर): .dimbreak-select-dimension-to-break = DIMBREAK तोड़ने के लिए आयाम का चयन करें: @@ -1802,8 +1813,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE कोई आधार आयाम नहीं मिला। पहले आयाम दें। .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE एक दूसरी विस्तार लाइन मूल निर्दिष्ट करें (बाहर निकलने के लिए बेहतर): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER आयाम टेक्स्ट दर्ज करें (blank = मापा मान): - .dimdiameter-specify-center-point = DIMDIAMETER केंद्र बिंदु निर्दिष्ट करें: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER आयाम लाइन स्थान निर्दिष्ट करें [टेक्स्ट / एंगल]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER आर्क, सर्कल या पॉलीलाइन आर्क चुनें: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER आयाम लाइन स्थान निर्दिष्ट करें [एमटेक्स्ट/टेक्स्ट/एंगल]: .dimdiameter-specify-point-on-circle = DIMDIAMETER सर्कल पर बिंदु निर्दिष्ट करें: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER पाठ कोण निर्दिष्ट करें (डिग्री): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT टेक्स्ट ओवरराइड (ब्लैंक = मापा गया करने के लिए रीसेट): @@ -1819,10 +1830,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR दूसरी एक्सटेंशन लाइन उत्पत्ति निर्दिष्ट करें [टेक्स्ट / एंगल]: .dimlinear-specify-text-angle-degrees = DIMLINEAR पाठ कोण निर्दिष्ट करें (डिग्री): .dimordinate-specify-feature-location = DIMORDINATE सुविधा स्थान निर्दिष्ट करें: - .dimordinate-specify-leader-endpoint = DIMORDINATE लीडर समापन बिंदु निर्दिष्ट करें: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE आयाम टेक्स्ट दर्ज करें (रिक्त = मापा मान): + .dimordinate-specify-leader-endpoint = DIMORDINATE लीडर समापन बिंदु निर्दिष्ट करें [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE पाठ कोण निर्दिष्ट करें (डिग्री): .dimradius-enter-dimension-text-blank-measured = DIMRADIUS आयाम टेक्स्ट दर्ज करें (blank = मापा मान): - .dimradius-specify-center-point = DIMRADIUS केंद्र बिंदु निर्दिष्ट करें: - .dimradius-specify-dimension-line-location-text = DIMRADIUS आयाम लाइन स्थान निर्दिष्ट करें [टेक्स्ट / एंगल]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS आर्क, सर्कल या पॉलीलाइन आर्क चुनें: + .dimradius-specify-dimension-line-location-text = DIMRADIUS आयाम लाइन स्थान निर्दिष्ट करें [एमटेक्स्ट/टेक्स्ट/एंगल]: .dimradius-specify-radius-point = DIMRADIUS त्रिज्या बिंदु निर्दिष्ट करें: .dimradius-specify-text-angle-degrees = DIMRADIUS पाठ कोण निर्दिष्ट करें (डिग्री): .dimspace-enter-value-0-auto = DIMSPACE मान दर्ज करें (0 = ऑटो): @@ -1854,6 +1867,7 @@ annotate = डेटा .lower = कम .mleader-specify-arrowhead-point = MLEADER निर्दिष्ट तीरहेड बिंदु: + .mleader-specify-landing-point = MLEADER लैंडिंग बिंदु निर्दिष्ट करें: .mleader-specify-next-point-count-pts-enter-to = MLEADER अगला बिंदु निर्दिष्ट करें [__ocs_arg_count__ बिंदु — टेक्स्ट रखने के लिए Enter दबाएँ]: .mleaderadd-select-a-multileader = MLEADERADD मल्टीलीडर चुनें: .mleaderadd-specify-arrowhead-location = MLEADERADD एरोहेड का स्थान निर्दिष्ट करें: @@ -2023,6 +2037,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ ऑब्जेक्ट क्लिपबोर्ड पर काटे गए। model = + .create = बनाएँ + .boolean = बूलियन संक्रियाएँ + .pyramid = पिरामिड + .polysolid = बहुखंड ठोस + .extrude = एक्सट्रूड + .revolve = घुमाएँ + .loft = लॉफ्ट + .sweep = स्वीप + .presspull = दबाएँ/खींचें .n-specify-center-point = __ocs_arg_n__ केंद्र बिंदु निर्दिष्ट करें: .n-specify-first-corner = __ocs_arg_n__ पहले कोने को निर्दिष्ट करें: .n-specify-height-enter-for-default = __ocs_arg_n__ ऊंचाई निर्दिष्ट करें के लिए दर्ज करें>: @@ -2323,6 +2346,7 @@ view = .zoom-extents = ज़ूम बहार + .cmd-no-hatch-objects = __ocs_fmt_0__: ÓñòÓÑïÓñê Óñ╣ÓÑêÓñÜ ÓñæÓñ¼ÓÑìÓñ£ÓÑçÓñòÓÑìÓñƒ Óñ¿Óñ╣ÓÑÇÓñéÓÑñ .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: कोई टेक्स्ट या डाइमेंशन ऑब्जेक्ट नहीं। .cmd-not-yet-implemented = __ocs_fmt_0__: अभी तक लागू नहीं है। .viewport-s-in-layout = लेआउट "__ocs_fmt_1__" में __ocs_fmt_0__ व्यूपोर्ट: @@ -2700,6 +2724,9 @@ properties = .plot-style-table = प्लॉट स्टाइल टेबल .plot-table-attached-to = से जुड़ी प्लॉट टेबल .plot-table-type = प्लॉट तालिका प्रकार + .color-dependent-plot-styles = रंग-निर्भर प्लॉट शैलियाँ + .named-plot-styles = नामित प्लॉट शैलियाँ + .plot-style-is-locked-to-color-in-color-dependent = रंग-निर्भर (CTB) मोड में प्लॉट शैली रंग से लॉक होती है .point-1-x = अंक 1 X .point-1-y = अंक 1 Y .point-1-z = प्वाइंट 1 diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 13e4c28c..cbcf8e15 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Rajz .annotate = Feliratozás .insert = Beszúrás - .model = Modell + .model = Modellezés .layout = Elrendezés .manage = Kezelés .view = Nézet @@ -332,6 +332,7 @@ common = .brightness = Fényerő .by-layer = Réteg szerint .byblock = ByBlock + .bycolor = Szín szerint .bylayer = ByLayer .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: __ocs_fmt_0__ szilárdtest(ek)et felületté alakított(ok). .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: válassza ki a munkamenetben létrehozott szilárdtestet. @@ -1798,14 +1799,24 @@ annotate = .collect-leaders = Gyűjtsd össze a vezetőket .ddedit-select-text-entity = DDEDIT Szöveg entitás kiválasztása: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED Enter méretszöveg (üres = mért érték): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED A méretvonal helyének megadása [szöveg/szög]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED Adja meg az első mellékvonal eredetét: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED Adja meg a második mellékvonal eredetét [szöveg/szög]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED Adja meg a formázott méretszöveget (üres = mért érték): + .dimaligned-select-object-to-dimension = DIMALIGNED Válassza ki a méretezendő objektumot: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Adja meg a méretvonal helyét [Mtext/Szöveg/Szög]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED Adja meg az első segédvonal kezdőpontját, vagy nyomja meg az Entert egy objektum kiválasztásához: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED Adja meg a második segédvonal kezdőpontját: .dimaligned-specify-text-angle-degrees = DIMALIGNED Adja meg a szöveg szögét (fokban): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR Adja meg a méretszöveget (üres = mért érték): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR Adja meg a formázott méretszöveget (üres = mért érték): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR Válasszon ívet, kört vagy vonalat, vagy adjon meg egy szögcsúcsot: + .dimangular-select-second-line = DIMANGULAR Válassza ki a második vonalat: .dimangular-specify-angle-vertex = DIMANGULAR Szögcsúcs megadása: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR Adja meg a méretív helyét [Mtext/Szöveg/Szög/Kvadráns]: .dimangular-specify-dimension-arc-location = DIMANGULAR A méretív helyének megadása: .dimangular-specify-first-extension-line-point = DIMANGULAR Adja meg az első hosszabbító vonalpontot: + .dimangular-specify-quadrant = DIMANGULAR Adja meg a kvadránst: .dimangular-specify-second-extension-line-point = DIMANGULAR Adja meg a második kiterjesztési vonalpontot: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR Adja meg a szövegszög második pontját: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR Adja meg a szövegszöget vagy az első pontot: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE Nem található alapméret. Először helyezzen el egy méretet. .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE Adjon meg egy második mellékvonal kezdőpontját (Enter a kilépéshez): .dimbreak-select-dimension-to-break = DIMBREAK Válassza ki a törni kívánt méretet: @@ -1813,8 +1824,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE Nem található alapméret. Először helyezzen el egy méretet. .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE Adjon meg egy második mellékvonal kezdőpontját (Enter a kilépéshez): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER Enter méretszöveg (üres = mért érték): - .dimdiameter-specify-center-point = DIMDIAMETER Középpont megadása: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER A méretvonal helyének megadása [szöveg/szög]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER Válasszon ívet, kört vagy vonalláncívet: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER A méretvonal helyének megadása [Mszöveg/szöveg/szög]: .dimdiameter-specify-point-on-circle = DIMDIAMETER Pont megadása a körön: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER Adja meg a szöveg szögét (fokban): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT Enter szöveg felülírása (üres = visszaállítása mért értékre): @@ -1830,10 +1841,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR Adja meg a második mellékvonal eredetét [szöveg/szög]: .dimlinear-specify-text-angle-degrees = DIMLINEAR Adja meg a szöveg szögét (fokban): .dimordinate-specify-feature-location = DIMORDINATE Jellemző helyének megadása: - .dimordinate-specify-leader-endpoint = DIMORDINATE Vezető végpont megadása: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE Adja meg a méretszöveget (üres = mért érték): + .dimordinate-specify-leader-endpoint = DIMORDINATE Vezető végpont megadása [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE Adja meg a szöveg szögét (fokban): .dimradius-enter-dimension-text-blank-measured = DIMRADIUS Enter méretszöveg (üres = mért érték): - .dimradius-specify-center-point = DIMRADIUS Középpont megadása: - .dimradius-specify-dimension-line-location-text = DIMRADIUS A méretvonal helyének megadása [szöveg/szög]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Válasszon ívet, kört vagy vonalláncívet: + .dimradius-specify-dimension-line-location-text = DIMRADIUS A méretvonal helyének megadása [Mszöveg/szöveg/szög]: .dimradius-specify-radius-point = DIMRADIUS Adja meg a sugárpontot: .dimradius-specify-text-angle-degrees = DIMRADIUS Adja meg a szöveg szögét (fokban): .dimspace-enter-value-0-auto = DIMSPACE Enter érték (0 = automatikus): @@ -1865,6 +1878,7 @@ annotate = Adatok .lower = Lejjebb .mleader-specify-arrowhead-point = MLEADER Nyílhegy pont megadása: + .mleader-specify-landing-point = MLEADER Bekötési pont megadása: .mleader-specify-next-point-count-pts-enter-to = MLEADER Adja meg a következő pontot [__ocs_arg_count__ pts — Enter a szöveg elhelyezéséhez]: .mleaderadd-select-a-multileader = MLEADERADD Válasszon többvezetőt: .mleaderadd-specify-arrowhead-location =MLEADERADD Adja meg a nyílhegy helyét: @@ -2034,6 +2048,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ objektum(ok) vágólapra vágva. model = + .create = Létrehozás + .boolean = Logikai műveletek + .pyramid = Gúla + .polysolid = Poliszilárdtest + .extrude = Kihúzás + .revolve = Forgatás + .loft = Átmenet + .sweep = Seprés + .presspull = Nyomás/húzás .n-specify-center-point = __ocs_arg_n__ Középpont megadása: .n-specify-first-corner = __ocs_arg_n__ Adja meg az első sarkot: .n-specify-height-enter-for-default = __ocs_arg_n__ Adja meg a magasságot: @@ -2334,6 +2357,7 @@ view = .zoom-extents = Zoom Terjedelem + .cmd-no-hatch-objects = __ocs_fmt_0__: nincs sraffoz├ísi objektum. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: nincs szöveg vagy méretobjektum. .cmd-not-yet-implemented = __ocs_fmt_0__: még nincs implementálva. .viewport-s-in-layout =__ocs_fmt_0__ nézetablak(ok) a „__ocs_fmt_1__” elrendezésben: @@ -2711,6 +2735,9 @@ properties = .plot-style-table = Telek stílusú táblázat .plot-table-attached-to =Telektábla csatolva .plot-table-type = Plot tábla típusa + .color-dependent-plot-styles = Színfüggő nyomtatási stílusok + .named-plot-styles = Elnevezett nyomtatási stílusok + .plot-style-is-locked-to-color-in-color-dependent = Színfüggő (CTB) módban a nyomtatási stílust a szín határozza meg .point-1-x = 1. pont X .point-1-y = 1. pont Y .point-1-z = 1. pont Z diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 5acada86..839d8fcb 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Disegno .annotate = Annotazione .insert = Inserisci - .model = Modello + .model = Modellazione .layout = Disposizione .manage = Gestisci .view = Vista @@ -332,6 +332,7 @@ common = .brightness = Luminosità .by-layer = Per strato .byblock = PerBlocco + .bycolor = PerColore .bylayer = PerLayer .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: convertito(i) solido(i) __ocs_fmt_0__ in superficie(i). .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: seleziona un solido creato in questa sessione. @@ -1798,14 +1799,24 @@ annotate = .collect-leaders = Colleziona leader .ddedit-select-text-entity = DDEDIT Seleziona entità testo: .dimaligned-enter-dimension-text-blank-measured = Testo della dimensione DIMALIGNED Enter (valore misurato = vuoto): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Specificare la posizione della linea di quota [Testo/Angolo]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED Specificare l'origine della prima linea di estensione: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED Specificare l'origine della seconda linea di estensione [Testo/Angolo]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED Immettere il testo di quota formattato (vuoto = valore misurato): + .dimaligned-select-object-to-dimension = DIMALIGNED Selezionare l'oggetto da quotare: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Specificare la posizione della linea di quota [Mtext/Testo/Angolo]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED Specificare l'origine della prima linea di estensione o premere Invio per selezionare un oggetto: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED Specificare l'origine della seconda linea di estensione: .dimaligned-specify-text-angle-degrees = DIMALIGNED Specificare l'angolo del testo (gradi): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR Immettere il testo di quota (vuoto = valore misurato): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR Immettere il testo di quota formattato (vuoto = valore misurato): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR Selezionare un arco, un cerchio o una linea oppure specificare un vertice dell'angolo: + .dimangular-select-second-line = DIMANGULAR Selezionare la seconda linea: .dimangular-specify-angle-vertex = DIMANGULAR Specificare il vertice dell'angolo: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR Specificare la posizione dell'arco di quota [Mtext/Testo/Angolo/Quadrante]: .dimangular-specify-dimension-arc-location = DIMANGULAR Specificare la posizione dell'arco di quota: .dimangular-specify-first-extension-line-point = DIMANGULAR Specificare il primo punto della linea di estensione: + .dimangular-specify-quadrant = DIMANGULAR Specificare il quadrante: .dimangular-specify-second-extension-line-point = DIMANGULAR Specificare il secondo punto della linea di estensione: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR Specificare il secondo punto per l'angolo del testo: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR Specificare l'angolo del testo o il primo punto: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE Nessuna dimensione base trovata. Posiziona prima una dimensione. .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE Specificare una seconda origine della linea di estensione (Enter per uscire): .dimbreak-select-dimension-to-break = DIMBREAK Selezionare la dimensione da interrompere: @@ -1813,8 +1824,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE Nessuna dimensione base trovata. Posiziona prima una dimensione. .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE Specificare una seconda origine della linea di estensione (Enter per uscire): .dimdiameter-enter-dimension-text-blank-measure = Testo della dimensione DIMDIAMETER Enter (valore misurato = vuoto): - .dimdiameter-specify-center-point = DIMDIAMETER Specificare il punto centrale: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER Specificare la posizione della linea di quota [Testo/Angolo]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER Selezionare un arco, un cerchio o un arco di polilinea: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER Specificare la posizione della linea di quota [Mtesto/Testo/Angolo]: .dimdiameter-specify-point-on-circle = DIMDIAMETER Specificare il punto sul cerchio: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER Specificare l'angolo del testo (gradi): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT Enter sovrascrittura testo (= vuoto ripristinato su misurato): @@ -1830,10 +1841,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR Specificare l'origine della seconda linea di estensione [Testo/Angolo]: .dimlinear-specify-text-angle-degrees = DIMLINEAR Specificare l'angolo del testo (gradi): .dimordinate-specify-feature-location = DIMORDINATE Specificare la posizione dell'elemento: - .dimordinate-specify-leader-endpoint = DIMORDINATE Specificare il punto finale della guida: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE Immettere il testo della quota (vuoto = valore misurato): + .dimordinate-specify-leader-endpoint = DIMORDINATE Specificare il punto finale della guida [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE Specificare l'angolo del testo (gradi): .dimradius-enter-dimension-text-blank-measured = Testo della dimensione DIMRADIUS Enter (valore misurato = vuoto): - .dimradius-specify-center-point = DIMRADIUS Specificare il punto centrale: - .dimradius-specify-dimension-line-location-text = DIMRADIUS Specificare la posizione della linea di quota [Testo/Angolo]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Selezionare un arco, un cerchio o un arco di polilinea: + .dimradius-specify-dimension-line-location-text = DIMRADIUS Specificare la posizione della linea di quota [Mtesto/Testo/Angolo]: .dimradius-specify-radius-point = DIMRADIUS Specificare il punto del raggio: .dimradius-specify-text-angle-degrees = DIMRADIUS Specificare l'angolo del testo (gradi): .dimspace-enter-value-0-auto = Valore DIMSPACE Enter (0 = automatico): @@ -1865,6 +1878,7 @@ annotate = Dati .lower = Più in basso .mleader-specify-arrowhead-point = MLEADER Specificare il punto della freccia: + .mleader-specify-landing-point = MLEADER Specificare il punto di collegamento: .mleader-specify-next-point-count-pts-enter-to = MLEADER Specificare il punto successivo [__ocs_arg_count__ punti — Enter per posizionare il testo]: .mleaderadd-select-a-multileader = MLEADERADD Seleziona una multidirettrice: .mleaderadd-specify-arrowhead-location =MLEADERADD Specificare la posizione della punta della freccia: @@ -2034,6 +2048,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ oggetto/i tagliato negli appunti. model = + .create = Crea + .boolean = Operazioni booleane + .pyramid = Piramide + .polysolid = Polisolido + .extrude = Estrudi + .revolve = Rivoluzione + .loft = Loft + .sweep = Scorrimento + .presspull = Premi/tira .n-specify-center-point = __ocs_arg_n__ Specificare il punto centrale: .n-specify-first-corner = __ocs_arg_n__ Specificare il primo angolo: .n-specify-height-enter-for-default = __ocs_arg_n__ Specificare l'altezza : @@ -2334,6 +2357,7 @@ view = .zoom-extents = Zoom Estensioni + .cmd-no-hatch-objects = __ocs_fmt_0__: nessun oggetto tratteggio. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: nessun testo o oggetto dimensione. .cmd-not-yet-implemented = __ocs_fmt_0__: non ancora implementato. .viewport-s-in-layout =Finestre __ocs_fmt_0__ nel layout "__ocs_fmt_1__": @@ -2711,6 +2735,9 @@ properties = .plot-style-table = Tabella stili di stampa .plot-table-attached-to =Tabella di stampa allegata a .plot-table-type = Tipo di tabella di stampa + .color-dependent-plot-styles = Stili di stampa dipendenti dal colore + .named-plot-styles = Stili di stampa con nome + .plot-style-is-locked-to-color-in-color-dependent = In modalità dipendente dal colore (CTB), lo stile di stampa è vincolato al colore .point-1-x = Punto 1 X .point-1-y = Punto 1 Y .point-1-z = Punto 1 Z diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index c35743c2..90fb13d8 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = 作図 .annotate = 注釈 .insert = 挿入 - .model = モデル + .model = モデリング .layout = レイアウト .manage = 管理 .view = 表示 @@ -323,6 +323,7 @@ common = .brightness = 明るさ .by-layer = レイヤー別 .byblock = ブロック依存 + .bycolor = 色依存 .bylayer = レイヤー依存 .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: __ocs_fmt_0__ 個のソリッドをサーフェスに変換しました。 .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: このセッションで作成したソリッドを選択してください。 @@ -1789,14 +1790,24 @@ annotate = .collect-leaders = 引出線を収集 .ddedit-select-text-entity = DDEDIT 文字図形を選択: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED 寸法値を入力(空白 = 計測値): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED 寸法線の位置を指定 [文字/角度]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED 1 本目の寸法補助線の起点を指定: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED 2 本目の寸法補助線の起点を指定 [文字/角度]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED 書式付き寸法文字を入力(空白 = 計測値): + .dimaligned-select-object-to-dimension = DIMALIGNED 寸法を記入するオブジェクトを選択: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED 寸法線の位置を指定 [Mtext/文字/角度]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED 1 本目の寸法補助線の起点を指定、または Enter でオブジェクトを選択: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED 2 本目の寸法補助線の起点を指定: .dimaligned-specify-text-angle-degrees = DIMALIGNED 文字角度を指定(度): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR 寸法値を入力(空白 = 計測値): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR 書式付き寸法文字を入力(空白 = 計測値): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR 円弧、円、線分を選択、または角度の頂点を指定: + .dimangular-select-second-line = DIMANGULAR 2 本目の線分を選択: .dimangular-specify-angle-vertex = DIMANGULAR 角度の頂点を指定: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR 寸法円弧の位置を指定 [Mtext/文字/角度/象限]: .dimangular-specify-dimension-arc-location = DIMANGULAR 寸法円弧の位置を指定: .dimangular-specify-first-extension-line-point = DIMANGULAR 1 本目の寸法補助線の点を指定: + .dimangular-specify-quadrant = DIMANGULAR 象限を指定: .dimangular-specify-second-extension-line-point = DIMANGULAR 2 本目の寸法補助線の点を指定: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR 文字角度の 2 点目を指定: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR 文字角度または 1 点目を指定: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE 基準寸法がありません。先に寸法を配置してください。 .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE 2 本目の寸法補助線の起点を指定(Enter で終了): .dimbreak-select-dimension-to-break = DIMBREAK 分割する寸法を選択: @@ -1804,8 +1815,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE 基準寸法がありません。先に寸法を配置してください。 .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE 2 本目の寸法補助線の起点を指定(Enter で終了): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER 寸法値を入力(空白 = 計測値): - .dimdiameter-specify-center-point = DIMDIAMETER 中心点を指定: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER 寸法線の位置を指定 [文字/角度]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER 円弧、円、またはポリライン円弧を選択: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER 寸法線の位置を指定 [マルチテキスト/文字/角度]: .dimdiameter-specify-point-on-circle = DIMDIAMETER 円上の点を指定: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER 文字角度を指定(度): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT 寸法値の上書きを入力(空白 = 計測値に戻す): @@ -1821,10 +1832,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR 2 本目の寸法補助線の起点を指定 [文字/角度]: .dimlinear-specify-text-angle-degrees = DIMLINEAR 文字角度を指定(度): .dimordinate-specify-feature-location = DIMORDINATE フィーチャ位置を指定: - .dimordinate-specify-leader-endpoint = DIMORDINATE 引出線の終点を指定: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE 寸法値を入力(空白 = 計測値): + .dimordinate-specify-leader-endpoint = DIMORDINATE 引出線の終点を指定 [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE 文字角度を指定(度): .dimradius-enter-dimension-text-blank-measured = DIMRADIUS 寸法値を入力(空白 = 計測値): - .dimradius-specify-center-point = DIMRADIUS 中心点を指定: - .dimradius-specify-dimension-line-location-text = DIMRADIUS 寸法線の位置を指定 [文字/角度]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS 円弧、円、またはポリライン円弧を選択: + .dimradius-specify-dimension-line-location-text = DIMRADIUS 寸法線の位置を指定 [マルチテキスト/文字/角度]: .dimradius-specify-radius-point = DIMRADIUS 半径点を指定: .dimradius-specify-text-angle-degrees = DIMRADIUS 文字角度を指定(度): .dimspace-enter-value-0-auto = DIMSPACE 値を入力(0 = 自動): @@ -1856,6 +1869,7 @@ annotate = リンク .lower = 下げる .mleader-specify-arrowhead-point = MLEADER 矢印位置を指定: + .mleader-specify-landing-point = MLEADER ランディング点を指定: .mleader-specify-next-point-count-pts-enter-to = MLEADER 次の点を指定 [__ocs_arg_count__ 点 — Enter で文字を配置]: .mleaderadd-select-a-multileader = MLEADERADD マルチ引出線を選択: .mleaderadd-specify-arrowhead-location = MLEADERADD 矢印位置を指定: @@ -2023,6 +2037,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ 個のオブジェクトを切り取ってクリップボードに保存しました。 model = + .create = 作成 + .boolean = ブール演算 + .pyramid = ピラミッド + .polysolid = ポリソリッド + .extrude = 押し出し + .revolve = 回転 + .loft = ロフト + .sweep = スイープ + .presspull = プレスプル .n-specify-center-point = __ocs_arg_n__ 中心点を指定: .n-specify-first-corner = __ocs_arg_n__ 最初のコーナーを指定: .n-specify-height-enter-for-default = __ocs_arg_n__ 高さを指定 : @@ -2323,6 +2346,7 @@ view = .zoom-extents = 全体表示 ズーム + .cmd-no-hatch-objects = __ocs_fmt_0__: ÒâÅÒââÒâüÒâ│Òé░ Òé¬ÒâûÒé©ÒéºÒé»ÒâêÒüîÒüéÒéèÒü¥ÒüøÒéôÒÇé .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: テキストまたは寸法オブジェクトがありません。 .cmd-not-yet-implemented = __ocs_fmt_0__: まだ実装されていません。 .viewport-s-in-layout = レイアウト「__ocs_fmt_1__」内の __ocs_fmt_0__ 個のビューポート: @@ -2700,6 +2724,9 @@ properties = .plot-style-table = 印刷スタイルテーブル .plot-table-attached-to = 印刷テーブルのアタッチ先 .plot-table-type = 印刷テーブルの種類 + .color-dependent-plot-styles = 色従属印刷スタイル + .named-plot-styles = 名前付き印刷スタイル + .plot-style-is-locked-to-color-in-color-dependent = 色従属(CTB)モードでは、印刷スタイルは色に固定されます .point-1-x = 点 1 X .point-1-y = 点 1 Y .point-1-z = 点 1 Z diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index d2c649d8..c773bb94 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = 그리기 .annotate = 주석 .insert = 삽입 - .model = 모델 + .model = 모델링 .layout = 배치 .manage = 관리 .view = 보기 @@ -332,6 +332,7 @@ common = .brightness = 밝기 .by-layer = 레이어별 .byblock = 바이블록 + .bycolor = 색상별 .bylayer = 레이어별 .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: __ocs_fmt_0__ 솔리드를 표면으로 변환했습니다. .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: 이 세션에서 생성된 솔리드를 선택합니다. @@ -1798,14 +1799,24 @@ annotate = .collect-leaders = 리더 수집 .ddedit-select-text-entity = DDEDIT 텍스트 엔터티 선택: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED Enter 치수 문자(빈 = 측정 값): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED 치수선 위치 지정 [문자/각도]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED 첫 번째 연장선 원점 지정: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED 두 번째 연장선 원점 지정 [문자/각도]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED 서식 있는 치수 문자를 입력하십시오(비워 두면 측정값): + .dimaligned-select-object-to-dimension = DIMALIGNED 치수를 기입할 객체 선택: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED 치수선 위치 지정 [Mtext/문자/각도]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED 첫 번째 치수 보조선 원점을 지정하거나 Enter를 눌러 객체 선택: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED 두 번째 치수 보조선 원점 지정: .dimaligned-specify-text-angle-degrees = DIMALIGNED 텍스트 각도(도) 지정: + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR 치수 문자를 입력하십시오(비워 두면 측정값): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR 서식 있는 치수 문자를 입력하십시오(비워 두면 측정값): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR 호, 원 또는 선을 선택하거나 각도 꼭지점을 지정하십시오: + .dimangular-select-second-line = DIMANGULAR 두 번째 선을 선택하십시오: .dimangular-specify-angle-vertex = DIMANGULAR 각도 꼭지점 지정: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR 치수 호 위치 지정 [Mtext/문자/각도/사분면]: .dimangular-specify-dimension-arc-location = DIMANGULAR 치수 호 위치를 지정합니다. .dimangular-specify-first-extension-line-point = DIMANGULAR 첫 번째 연장선 점 지정: + .dimangular-specify-quadrant = DIMANGULAR 사분면을 지정하십시오: .dimangular-specify-second-extension-line-point = DIMANGULAR 두 번째 연장선 점을 지정합니다. + .dimangular-specify-second-point-for-text-angle = DIMANGULAR 문자 각도의 두 번째 점을 지정하십시오: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR 문자 각도 또는 첫 번째 점을 지정하십시오: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE 기본 치수를 찾을 수 없습니다. 먼저 치수를 배치합니다. .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE 두 번째 연장선 원점을 지정합니다(종료하려면 Enter). .dimbreak-select-dimension-to-break = DIMBREAK 끊을 치수 선택: @@ -1813,8 +1824,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE 기본 치수를 찾을 수 없습니다. 먼저 치수를 배치합니다. .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE 두 번째 연장선 원점을 지정합니다(종료하려면 Enter). .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER Enter 치수 문자(빈 = 측정 값): - .dimdiameter-specify-center-point = DIMDIAMETER 중심점 지정: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER 치수선 위치 지정 [문자/각도]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER 호, 원 또는 폴리선 호 선택: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER 치수선 위치 지정 [여러 줄 문자/문자/각도]: .dimdiameter-specify-point-on-circle = DIMDIAMETER 원의 점 지정: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER 텍스트 각도(도) 지정: .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT Enter 텍스트 재정의(빈 =가 측정으로 재설정됨): @@ -1830,10 +1841,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR 두 번째 연장선 원점 지정 [문자/각도]: .dimlinear-specify-text-angle-degrees = DIMLINEAR 텍스트 각도(도) 지정: .dimordinate-specify-feature-location = DIMORDINATE 기능 위치 지정: - .dimordinate-specify-leader-endpoint = DIMORDINATE 리더 끝점 지정: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE 치수 문자 입력(빈 값 = 측정 값): + .dimordinate-specify-leader-endpoint = DIMORDINATE 리더 끝점 지정 [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE 텍스트 각도(도) 지정: .dimradius-enter-dimension-text-blank-measured = DIMRADIUS Enter 치수 문자(빈 = 측정 값): - .dimradius-specify-center-point = DIMRADIUS 중심점 지정: - .dimradius-specify-dimension-line-location-text = DIMRADIUS 치수선 위치 지정 [문자/각도]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS 호, 원 또는 폴리선 호 선택: + .dimradius-specify-dimension-line-location-text = DIMRADIUS 치수선 위치 지정 [여러 줄 문자/문자/각도]: .dimradius-specify-radius-point = DIMRADIUS 반경 점 지정: .dimradius-specify-text-angle-degrees = DIMRADIUS 텍스트 각도(도) 지정: .dimspace-enter-value-0-auto = DIMSPACE Enter 값(0 = 자동): @@ -1865,6 +1878,7 @@ annotate = 데이터 .lower = 낮은 .mleader-specify-arrowhead-point = MLEADER 화살촉 점 지정: + .mleader-specify-landing-point = MLEADER 연결점 지정: .mleader-specify-next-point-count-pts-enter-to = MLEADER 다음 점 지정 [__ocs_arg_count__ 포인트 - 텍스트를 배치할 Enter]: .mleaderadd-select-a-multileader = MLEADERADD 다중 지시선 선택: .mleaderadd-specify-arrowhead-location =MLEADERADD 화살촉 위치 지정: @@ -2034,6 +2048,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ 개체를 클립보드로 잘라냈습니다. model = + .create = 생성 + .boolean = 불리언 연산 + .pyramid = 피라미드 + .polysolid = 폴리솔리드 + .extrude = 돌출 + .revolve = 회전 + .loft = 로프트 + .sweep = 스윕 + .presspull = 누르기/당기기 .n-specify-center-point = __ocs_arg_n__ 중심점 지정: .n-specify-first-corner = __ocs_arg_n__ 첫 번째 구석 지정: .n-specify-height-enter-for-default = __ocs_arg_n__ 높이 지정 : @@ -2334,6 +2357,7 @@ view = .zoom-extents = 줌 범위 + .cmd-no-hatch-objects = __ocs_fmt_0__: Ýò┤ý╣ÿ Û░Øý▓┤Û░Ç ýùåýèÁÙïêÙïñ. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: 문자 또는 치수 객체가 없습니다. .cmd-not-yet-implemented = __ocs_fmt_0__: 아직 구현되지 않았습니다. .viewport-s-in-layout ="__ocs_fmt_1__" 레이아웃의 __ocs_fmt_0__ 뷰포트: @@ -2711,6 +2735,9 @@ properties = .plot-style-table = 플롯 스타일 테이블 .plot-table-attached-to =플롯 테이블이 첨부됨 .plot-table-type = 플롯 테이블 유형 + .color-dependent-plot-styles = 색상 종속 플롯 스타일 + .named-plot-styles = 명명된 플롯 스타일 + .plot-style-is-locked-to-color-in-color-dependent = 색상 종속(CTB) 모드에서는 플롯 스타일이 색상에 고정됩니다 .point-1-x = 포인트 1 X .point-1-y = 포인트 1 Y .point-1-z = 포인트 1 Z diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 246e073f..bc1bec66 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Tekenen .annotate = Annoteren .insert = Invoegen - .model = Model + .model = Modelleren .layout = Indeling .manage = Beheren .view = Beeld @@ -333,6 +333,7 @@ common = .brightness = Helderheid .by-layer = Op laag .byblock = Per blok + .bycolor = Per kleur .bylayer = DoorLayer .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: omgezet __ocs_fmt_0__ vaste stof(s) naar oppervlakte(s). .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: selecteer een vaste aangemaakte sessie. @@ -1799,14 +1800,24 @@ annotate = .collect-leaders = Verwijslijnen verzamelen .ddedit-select-text-entity = DDEDIT Selecteer een tekstobject: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED Voer maattekst in (leeg = gemeten waarde): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Geef de positie van de maatlijn op [Tekst/Hoek]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED Vermeld de oorsprong van de eerste extensieregel: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED Vermeld de oorsprong van de tweede extensieregel [Tekst/hoek]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED Voer opgemaakte maattekst in (leeg = gemeten waarde): + .dimaligned-select-object-to-dimension = DIMALIGNED Selecteer het object om te bematen: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Geef de positie van de maatlijn op [Mtext/Tekst/Hoek]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED Geef het beginpunt van de eerste hulplijn op of druk op Enter om een object te selecteren: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED Geef het beginpunt van de tweede hulplijn op: .dimaligned-specify-text-angle-degrees = DIMALIGNED Teksthoek (graden) specificeren: + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR Voer maattekst in (leeg = gemeten waarde): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR Voer opgemaakte maattekst in (leeg = gemeten waarde): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR Selecteer een boog, cirkel of lijn, of geef een hoekpunt op: + .dimangular-select-second-line = DIMANGULAR Selecteer de tweede lijn: .dimangular-specify-angle-vertex = DIMANGULAR Specificeer hoekvertex: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR Geef de positie van de maatboog op [Mtext/Tekst/Hoek/Kwadrant]: .dimangular-specify-dimension-arc-location = DIMANGULAR Geef de positie van de maatboog op: .dimangular-specify-first-extension-line-point = DIMANGULAR Geef eerste extensieregelpunt aan: + .dimangular-specify-quadrant = DIMANGULAR Geef het kwadrant op: .dimangular-specify-second-extension-line-point = DIMANGULAR Geef tweede extensieregelpunt: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR Geef het tweede punt voor de teksthoek op: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR Geef de teksthoek of het eerste punt op: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE Geen basismaat gevonden. Plaats eerst een maat. .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE Vermeld een tweede extensielijn (tot afsluiten overgaan): .dimbreak-select-dimension-to-break = DIMBREAK Selecteer de te onderbreken maat: @@ -1814,8 +1825,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE Geen basismaat gevonden. Plaats eerst een maat. .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE Vermeld een tweede extensielijn (tot afsluiten overgaan): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER Voer maattekst in (leeg = gemeten waarde): - .dimdiameter-specify-center-point = DIMDIAMETER Centrumpunt opgeven: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER Geef de positie van de maatlijn op [Tekst/Hoek]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER Selecteer boog, cirkel of polylijnboog: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER Geef de positie van de maatlijn op [M-tekst/Tekst/Hoek]: .dimdiameter-specify-point-on-circle = DIMDIAMETER Punt op cirkel specificeren: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER Teksthoek (graden) specificeren: .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT Tekstoverride invoeren (blanc = reset op gemeten): @@ -1831,10 +1842,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR Vermeld de oorsprong van de tweede extensieregel [Tekst/hoek]: .dimlinear-specify-text-angle-degrees = DIMLINEAR Teksthoek (graden) specificeren: .dimordinate-specify-feature-location = DIMORDINATE Geef functielocatie op: - .dimordinate-specify-leader-endpoint = DIMORDINATE Geef het eindpunt van de verwijslijn op: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE Voer maattekst in (leeg = gemeten waarde): + .dimordinate-specify-leader-endpoint = DIMORDINATE Geef het eindpunt van de verwijslijn op [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE Teksthoek (graden) specificeren: .dimradius-enter-dimension-text-blank-measured = DIMRADIUS Voer maattekst in (leeg = gemeten waarde): - .dimradius-specify-center-point = DIMRADIUS Centrumpunt opgeven: - .dimradius-specify-dimension-line-location-text = DIMRADIUS Geef de positie van de maatlijn op [Tekst/Hoek]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Selecteer boog, cirkel of polylijnboog: + .dimradius-specify-dimension-line-location-text = DIMRADIUS Geef de positie van de maatlijn op [M-tekst/Tekst/Hoek]: .dimradius-specify-radius-point = DIMRADIUS radiuspunt specificeren: .dimradius-specify-text-angle-degrees = DIMRADIUS Teksthoek (graden) specificeren: .dimspace-enter-value-0-auto = DIMSPACE Waarde invoeren (0 = auto): @@ -1866,6 +1879,7 @@ annotate = Gegevens .lower = Lager .mleader-specify-arrowhead-point = MLEADER pijlpunt specificeren: + .mleader-specify-landing-point = MLEADER Landingspunt opgeven: .mleader-specify-next-point-count-pts-enter-to = MLEADER Geef het volgende punt op [__ocs_arg_count__ punten — Enter om tekst te plaatsen]: .mleaderadd-select-a-multileader = MLEADERADD Selecteer een multileader: .mleaderadd-specify-arrowhead-location = MLEADERADD Plaats pijlpunt opgeven: @@ -2035,6 +2049,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ object(en) naar het klembord geknipt. model = + .create = Maken + .boolean = Booleaanse bewerkingen + .pyramid = Piramide + .polysolid = Polysolid + .extrude = Extruderen + .revolve = Omwentelen + .loft = Loft + .sweep = Vegen + .presspull = Duwen/trekken .n-specify-center-point = __ocs_arg_n__ Centrumpunt opgeven: .n-specify-first-corner = __ocs_arg_n__ Geef eerste hoek: .n-specify-height-enter-for-default = __ocs_arg_n__ Hoogte opgeven: @@ -2335,6 +2358,7 @@ view = .zoom-extents = Zoomen Maten + .cmd-no-hatch-objects = __ocs_fmt_0__: geen arceerobjecten. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: geen tekst- of maatobjecten. .cmd-not-yet-implemented = __ocs_fmt_0__: nog niet geïmplementeerd. .viewport-s-in-layout = __ocs_fmt_0__ weergavevenster(s) in indeling "__ocs_fmt_1__": @@ -2712,6 +2736,9 @@ properties = .plot-style-table = Plotstijltabel .plot-table-attached-to = Plottabel bijvoegen .plot-table-type = Type plotstijltabel + .color-dependent-plot-styles = Kleurafhankelijke plotstijlen + .named-plot-styles = Benoemde plotstijlen + .plot-style-is-locked-to-color-in-color-dependent = In kleurafhankelijke modus (CTB) is de plotstijl aan de kleur gekoppeld .point-1-x = Punt 1 X .point-1-y = Punt 1 Y .point-1-z = Punt 1 Z diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index cf5a981e..01d49a07 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Rysowanie .annotate = Opisy .insert = Wstaw - .model = Model + .model = Modelowanie .layout = Układ .manage = Zarządzaj .view = Widok @@ -332,6 +332,7 @@ common = .brightness = Jasność .by-layer = Według warstwy .byblock = ByBlock + .bycolor = Według koloru .bylayer = Według warstwy .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: przekształcono bryłę __ocs_fmt_0__ w powierzchnię. .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: wybierz bryłę utworzoną w tej sesji. @@ -1798,14 +1799,24 @@ annotate = .collect-leaders = Zbieraj Liderów .ddedit-select-text-entity = DDEDIT Wybierz element tekstowy: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED Enter tekst wymiarowy (pusta wartość zmierzona =): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Określ położenie linii wymiarowej [Tekst/Kąt]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED Określ początek pierwszej linii pomocniczej: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED Określ początek drugiej linii pomocniczej [Tekst/Kąt]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED Wprowadź sformatowany tekst wymiaru (puste = zmierzona wartość): + .dimaligned-select-object-to-dimension = DIMALIGNED Wybierz obiekt do zwymiarowania: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Określ położenie linii wymiarowej [Mtext/Tekst/Kąt]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED Określ początek pierwszej linii pomocniczej lub naciśnij Enter, aby wybrać obiekt: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED Określ początek drugiej linii pomocniczej: .dimaligned-specify-text-angle-degrees = DIMALIGNED Określ kąt tekstu (w stopniach): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR Wprowadź tekst wymiaru (puste = zmierzona wartość): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR Wprowadź sformatowany tekst wymiaru (puste = zmierzona wartość): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR Wybierz łuk, okrąg lub linię albo określ wierzchołek kąta: + .dimangular-select-second-line = DIMANGULAR Wybierz drugą linię: .dimangular-specify-angle-vertex = DIMANGULAR Określ wierzchołek kąta: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR Określ położenie łuku wymiarowego [Mtext/Tekst/Kąt/Ćwiartka]: .dimangular-specify-dimension-arc-location = DIMANGULAR Określ położenie łuku wymiarowego: .dimangular-specify-first-extension-line-point = DIMANGULAR Określ pierwszy punkt linii pomocniczej: + .dimangular-specify-quadrant = DIMANGULAR Określ ćwiartkę: .dimangular-specify-second-extension-line-point = DIMANGULAR Określ drugi punkt linii pomocniczej: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR Określ drugi punkt kąta tekstu: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR Określ kąt tekstu lub pierwszy punkt: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE Nie znaleziono wymiaru podstawowego. Najpierw umieść wymiar. .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE Określ początek drugiej linii pomocniczej (Enter, aby wyjść): .dimbreak-select-dimension-to-break = DIMBREAK Wybierz wymiar do przerwania: @@ -1813,8 +1824,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE Nie znaleziono wymiaru podstawowego. Najpierw umieść wymiar. .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE Określ początek drugiej linii pomocniczej (Enter, aby wyjść): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER Enter tekst wymiarowy (pusta wartość zmierzona =): - .dimdiameter-specify-center-point = DIMDIAMETER Określ punkt środkowy: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER Określ położenie linii wymiarowej [Tekst/Kąt]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER Wybierz łuk, okrąg lub łuk polilinii: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER Określ położenie linii wymiarowej [Mtekst/Tekst/Kąt]: .dimdiameter-specify-point-on-circle = DIMDIAMETER Określ punkt na okręgu: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER Określ kąt tekstu (w stopniach): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT Enter zastąpienie tekstu (puste = resetowane do zmierzonego): @@ -1830,10 +1841,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR Określ początek drugiej linii pomocniczej [Tekst/Kąt]: .dimlinear-specify-text-angle-degrees = DIMLINEAR Określ kąt tekstu (w stopniach): .dimordinate-specify-feature-location = DIMORDINATE Określ lokalizację elementu: - .dimordinate-specify-leader-endpoint = DIMORDINATE Określ punkt końcowy linii odniesienia: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE Wprowadź tekst wymiarowy (puste = wartość zmierzona): + .dimordinate-specify-leader-endpoint = DIMORDINATE Określ punkt końcowy linii odniesienia [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE Określ kąt tekstu (w stopniach): .dimradius-enter-dimension-text-blank-measured = DIMRADIUS Enter tekst wymiarowy (pusta wartość zmierzona =): - .dimradius-specify-center-point = DIMRADIUS Określ punkt środkowy: - .dimradius-specify-dimension-line-location-text = DIMRADIUS Określ położenie linii wymiarowej [Tekst/Kąt]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Wybierz łuk, okrąg lub łuk polilinii: + .dimradius-specify-dimension-line-location-text = DIMRADIUS Określ położenie linii wymiarowej [Mtekst/Tekst/Kąt]: .dimradius-specify-radius-point = DIMRADIUS Określ punkt promienia: .dimradius-specify-text-angle-degrees = DIMRADIUS Określ kąt tekstu (w stopniach): .dimspace-enter-value-0-auto = Wartość DIMSPACE Enter (0 = auto): @@ -1865,6 +1878,7 @@ annotate = Dane .lower = Niższy .mleader-specify-arrowhead-point = MLEADER Określ punkt grotu strzałki: + .mleader-specify-landing-point = MLEADER Określ punkt odsadzenia: .mleader-specify-next-point-count-pts-enter-to = MLEADER Określ następny punkt [__ocs_arg_count__ pkt — Enter, aby umieścić tekst]: .mleaderadd-select-a-multileader = MLEADERADD Wybierz wielolinię odniesienia: .mleaderadd-specify-arrowhead-location =MLEADERADD Określ położenie grotu strzałki: @@ -2034,6 +2048,15 @@ insert = .object-s-cut-to-clipboard = Obiekt(y) __ocs_fmt_0__ wycięty do schowka. model = + .create = Utwórz + .boolean = Operacje boolowskie + .pyramid = Ostrosłup + .polysolid = Bryła wielosegmentowa + .extrude = Wyciągnij + .revolve = Obróć + .loft = Loft + .sweep = Przeciągnij + .presspull = Naciśnij/wyciągnij .n-specify-center-point = __ocs_arg_n__ Określ punkt środkowy: .n-specify-first-corner = __ocs_arg_n__ Określ pierwszy narożnik: .n-specify-height-enter-for-default = __ocs_arg_n__ Podaj wysokość : @@ -2334,6 +2357,7 @@ view = .zoom-extents = Powiększ Zakresy + .cmd-no-hatch-objects = __ocs_fmt_0__: brak obiekt├│w kreskowania. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: brak obiektów tekstowych i wymiarowych. .cmd-not-yet-implemented = __ocs_fmt_0__: jeszcze nie wdrożony. .viewport-s-in-layout =Okna widokowe __ocs_fmt_0__ w układzie „__ocs_fmt_1__”: @@ -2711,6 +2735,9 @@ properties = .plot-style-table = Tabela stylów wydruku .plot-table-attached-to =Tabela wykresów dołączona do .plot-table-type = Typ tabeli wykresów + .color-dependent-plot-styles = Style wydruku zależne od koloru + .named-plot-styles = Nazwane style wydruku + .plot-style-is-locked-to-color-in-color-dependent = W trybie zależnym od koloru (CTB) styl wydruku jest przypisany do koloru .point-1-x = Punkt 1 X .point-1-y = Punkt 1 Y .point-1-z = Punkt 1 Z diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 128a43dc..65d5bc6f 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Desenhar .annotate = Anotar .insert = Inserir - .model = Modelo + .model = Modelagem .layout = Layout .manage = Gerenciar .view = Exibir @@ -333,6 +333,7 @@ common = .brightness = Brilho .by-layer = Por camada .byblock = PorBloco + .bycolor = PorCor .bylayer = PorCamada .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: __ocs_fmt_0__ sólidos convertidos em superfícies. .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: selecione um sólido criado nesta sessão. @@ -1799,14 +1800,24 @@ annotate = .collect-leaders = Coletar linhas de chamada .ddedit-select-text-entity = DDEDIT Selecione a entidade de texto: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED Insira o texto da cota (vazio = valor medido): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Especifique a localização da linha de cota [Texto/Ângulo]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED Especifique a origem da primeira linha de extensão: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED Especifique a origem da segunda linha de extensão [Texto/Ângulo]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED Insira o texto formatado da cota (vazio = valor medido): + .dimaligned-select-object-to-dimension = DIMALIGNED Selecione o objeto a ser cotado: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Especifique a localização da linha de cota [Mtext/Texto/Ângulo]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED Especifique a origem da primeira linha de extensão ou pressione Enter para selecionar um objeto: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED Especifique a origem da segunda linha de extensão: .dimaligned-specify-text-angle-degrees = DIMALIGNED Especifique o ângulo do texto (graus): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR Insira o texto da cota (vazio = valor medido): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR Insira o texto formatado da cota (vazio = valor medido): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR Selecione arco, círculo, linha ou especifique um vértice de ângulo: + .dimangular-select-second-line = DIMANGULAR Selecione a segunda linha: .dimangular-specify-angle-vertex = DIMANGULAR Especifique o vértice do ângulo: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR Especifique a localização do arco de cota [Mtext/Texto/Ângulo/Quadrante]: .dimangular-specify-dimension-arc-location = DIMANGULAR Especifique a localização do arco de cota: .dimangular-specify-first-extension-line-point = DIMANGULAR Especifique o ponto da primeira linha de extensão: + .dimangular-specify-quadrant = DIMANGULAR Especifique o quadrante: .dimangular-specify-second-extension-line-point = DIMANGULAR Especifique o ponto da segunda linha de extensão: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR Especifique o segundo ponto para o ângulo do texto: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR Especifique o ângulo do texto ou o primeiro ponto: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE Nenhuma cota base encontrada. Coloque uma cota primeiro. .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE Especifique a origem de uma segunda linha de extensão (Enter para sair): .dimbreak-select-dimension-to-break = DIMBREAK Selecione a cota a quebrar: @@ -1814,8 +1825,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE Nenhuma cota base encontrada. Coloque uma cota primeiro. .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE Especifique a origem de uma segunda linha de extensão (Enter para sair): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER Insira o texto da cota (vazio = valor medido): - .dimdiameter-specify-center-point = DIMDIAMETER Especifique o ponto central: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER Especifique a localização da linha de cota [Texto/Ângulo]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER Selecione um arco, círculo ou arco de polilinha: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER Especifique a localização da linha de cota [Mtexto/Texto/Ângulo]: .dimdiameter-specify-point-on-circle = DIMDIAMETER Especifique um ponto no círculo: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER Especifique o ângulo do texto (graus): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT Insira a substituição do texto (vazio = restaurar ao medido): @@ -1831,10 +1842,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR Especifique a origem da segunda linha de extensão [Texto/Ângulo]: .dimlinear-specify-text-angle-degrees = DIMLINEAR Especifique o ângulo do texto (graus): .dimordinate-specify-feature-location = DIMORDINATE Especifique a localização da característica: - .dimordinate-specify-leader-endpoint = DIMORDINATE Especifique a extremidade da linha de chamada: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE Insira o texto da cota (vazio = valor medido): + .dimordinate-specify-leader-endpoint = DIMORDINATE Especifique a extremidade da linha de chamada [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE Especifique o ângulo do texto (graus): .dimradius-enter-dimension-text-blank-measured = DIMRADIUS Insira o texto da cota (vazio = valor medido): - .dimradius-specify-center-point = DIMRADIUS Especifique o ponto central: - .dimradius-specify-dimension-line-location-text = DIMRADIUS Especifique a localização da linha de cota [Texto/Ângulo]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Selecione um arco, círculo ou arco de polilinha: + .dimradius-specify-dimension-line-location-text = DIMRADIUS Especifique a localização da linha de cota [Mtexto/Texto/Ângulo]: .dimradius-specify-radius-point = DIMRADIUS Especifique o ponto do raio: .dimradius-specify-text-angle-degrees = DIMRADIUS Especifique o ângulo do texto (graus): .dimspace-enter-value-0-auto = DIMSPACE Insira o valor (0 = automático): @@ -1866,6 +1879,7 @@ annotate = dados .lower = Inferior .mleader-specify-arrowhead-point = MLEADER Especifique o ponto da ponta de seta: + .mleader-specify-landing-point = MLEADER Especifique o ponto de conexão: .mleader-specify-next-point-count-pts-enter-to = MLEADER Especifique o próximo ponto [__ocs_arg_count__ pontos — Enter para inserir texto]: .mleaderadd-select-a-multileader = MLEADERADD Selecione uma linha de chamada múltipla: .mleaderadd-specify-arrowhead-location = MLEADERADD Especifique a localização da ponta de seta: @@ -2035,6 +2049,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ objetos recortados para a área de transferência. model = + .create = Criar + .boolean = Operações booleanas + .pyramid = Pirâmide + .polysolid = Polissólido + .extrude = Extrusão + .revolve = Revolução + .loft = Loft + .sweep = Varredura + .presspull = Pressionar/puxar .n-specify-center-point = __ocs_arg_n__ Especifique o ponto central: .n-specify-first-corner = __ocs_arg_n__ Especifique o primeiro canto: .n-specify-height-enter-for-default = __ocs_arg_n__ Especifique a altura : @@ -2335,6 +2358,7 @@ view = .zoom-extents = Zoom nas extensões + .cmd-no-hatch-objects = __ocs_fmt_0__: nenhum objeto de hachura. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: nenhum objeto de texto ou cota. .cmd-not-yet-implemented = __ocs_fmt_0__: ainda não implementado. .viewport-s-in-layout = __ocs_fmt_0__ janelas de visualização no layout “__ocs_fmt_1__”: @@ -2712,6 +2736,9 @@ properties = .plot-style-table = Tabela de estilos de plotagem .plot-table-attached-to = Tabela de plotagem anexada a .plot-table-type = Tipo de tabela de plotagem + .color-dependent-plot-styles = Estilos de plotagem dependentes de cor + .named-plot-styles = Estilos de plotagem nomeados + .plot-style-is-locked-to-color-in-color-dependent = No modo dependente de cor (CTB), o estilo de plotagem fica vinculado à cor .point-1-x = Ponto 1 X .point-1-y = Ponto 1 Y .point-1-z = Ponto 1 Z diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 0f154f49..bf760ae8 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Начертить .annotate = Аннотации .insert = Вставка - .model = Модель + .model = Моделирование .layout = Расположение .manage = Управление .view = Просмотр @@ -336,6 +336,7 @@ common = .brightness = Яркость .by-layer = слой .byblock = Блокировка + .bycolor = По цвету .bylayer = ByLayer .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: преобразовано __ocs_fmt_0__ твердое(ые) в поверхность(ы). .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: выберите твердое решение, созданное в ходе этой сессии. @@ -1802,14 +1803,24 @@ annotate = .collect-leaders = Собрать выноски .ddedit-select-text-entity = DDEDIT Выберите текстовый объект: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED Введите текст размера (пусто = измеренное значение): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Укажите положение размерной линии [Текст/Угол]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED Укажите происхождение первой линии расширения: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED Укажите происхождение второй линии расширения [Текст/Угле]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED Введите форматированный текст размера (пусто = измеренное значение): + .dimaligned-select-object-to-dimension = DIMALIGNED Выберите объект для нанесения размера: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Укажите положение размерной линии [Mtext/Текст/Угол]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED Укажите начало первой выносной линии или нажмите Enter для выбора объекта: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED Укажите начало второй выносной линии: .dimaligned-specify-text-angle-degrees = DIMALIGNED Укажите угол текста (градусы): - .dimangular-specify-angle-vertex = DIMANGULAR Укажите угол вершины: - .dimangular-specify-dimension-arc-location = DIMANGULAR Укажите местоположение дуги размерности: - .dimangular-specify-first-extension-line-point = DIMANGULAR Укажите первую точку расширения: - .dimangular-specify-second-extension-line-point = DIMANGULAR Укажите вторую точку расширения: + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR Введите текст размера (пусто = измеренное значение): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR Введите форматированный текст размера (пусто = измеренное значение): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR Выберите дугу, окружность, линию или укажите вершину угла: + .dimangular-select-second-line = DIMANGULAR Выберите вторую линию: + .dimangular-specify-angle-vertex = DIMANGULAR Укажите вершину угла: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR Укажите положение размерной дуги [Mtext/Текст/Угол/Квадрант]: + .dimangular-specify-dimension-arc-location = DIMANGULAR Укажите положение размерной дуги: + .dimangular-specify-first-extension-line-point = DIMANGULAR Укажите первую точку выносной линии: + .dimangular-specify-quadrant = DIMANGULAR Укажите квадрант: + .dimangular-specify-second-extension-line-point = DIMANGULAR Укажите вторую точку выносной линии: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR Укажите вторую точку угла текста: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR Укажите угол текста или первую точку: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE Базовый размер не найден. Сначала создайте размер. .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE Укажите происхождение второй линии расширения (Введите для выхода): .dimbreak-select-dimension-to-break = DIMBREAK Выберите размер для разбиения: @@ -1817,8 +1828,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE Базовый размер не найден. Сначала создайте размер. .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE Укажите происхождение второй линии расширения (Введите для выхода): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER Введите текст размера (пусто = измеренное значение): - .dimdiameter-specify-center-point = DIMDIAMETER Укажите центральную точку: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER Укажите положение размерной линии [Текст/Угол]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER Выберите дугу, окружность или дуговой сегмент полилинии: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER Укажите положение размерной линии [Мтекст/Текст/Угол]: .dimdiameter-specify-point-on-circle = DIMDIAMETER Укажите точку на окружности: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER Укажите угол текста (градусы): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT Введите текстовую оверрайд (blank = reset to measured): @@ -1834,10 +1845,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR Укажите происхождение второй линии расширения [Текст/Угле]: .dimlinear-specify-text-angle-degrees = DIMLINEAR Укажите угол текста (градусы): .dimordinate-specify-feature-location = DIMORDINATE Укажите местоположение: - .dimordinate-specify-leader-endpoint = DIMORDINATE Укажите конечную точку выноски: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE Введите текст размера (пусто = измеренное значение): + .dimordinate-specify-leader-endpoint = DIMORDINATE Укажите конечную точку выноски [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE Укажите угол текста (градусы): .dimradius-enter-dimension-text-blank-measured = DIMRADIUS Введите текст размера (пусто = измеренное значение): - .dimradius-specify-center-point = DIMRADIUS Укажите центральную точку: - .dimradius-specify-dimension-line-location-text = DIMRADIUS Укажите положение размерной линии [Текст/Угол]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Выберите дугу, окружность или дуговой сегмент полилинии: + .dimradius-specify-dimension-line-location-text = DIMRADIUS Укажите положение размерной линии [Мтекст/Текст/Угол]: .dimradius-specify-radius-point = DIMRADIUS Укажите точку радиуса: .dimradius-specify-text-angle-degrees = DIMRADIUS Укажите угол текста (градусы): .dimspace-enter-value-0-auto = DIMSPACE Введите значение (0 = авто): @@ -1869,6 +1882,7 @@ annotate = Данные .lower = ниже .mleader-specify-arrowhead-point = MLEADER Укажите точку наконечника стрелки: + .mleader-specify-landing-point = MLEADER Укажите точку полки: .mleader-specify-next-point-count-pts-enter-to = MLEADER Укажите следующую точку [точек: __ocs_arg_count__ — Enter для размещения текста]: .mleaderadd-select-a-multileader = MLEADERADD Выберите мультивыноску: .mleaderadd-specify-arrowhead-location = MLEADERADD Укажите местоположение наконечника стрелки: @@ -2038,6 +2052,15 @@ insert = .object-s-cut-to-clipboard = В буфер обмена вырезано объектов: __ocs_fmt_0__. model = + .create = Создание + .boolean = Булевы операции + .pyramid = Пирамида + .polysolid = Политело + .extrude = Выдавливание + .revolve = Вращение + .loft = Лофт + .sweep = Сдвиг по траектории + .presspull = Нажать/вытянуть .n-specify-center-point = __ocs_arg_n__ Укажите центральную точку: .n-specify-first-corner = __ocs_arg_n__ Укажите первый угол: .n-specify-height-enter-for-default = __ocs_arg_n__ Укажите высоту <Введите по умолчанию>: @@ -2338,6 +2361,7 @@ view = .zoom-extents = Зум размеры + .cmd-no-hatch-objects = __ocs_fmt_0__: ð¢ðÁÐé ð¥ð▒ÐèðÁð║Ðéð¥ð▓ ÐêÐéÐÇð©Ðàð¥ð▓ð║ð©. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: нет текстовых или размерных объектов. .cmd-not-yet-implemented = __ocs_fmt_0__: еще не реализовано. .viewport-s-in-layout = Видовые экраны (__ocs_fmt_0__) на листе «__ocs_fmt_1__»: @@ -2715,6 +2739,9 @@ properties = .plot-style-table = Таблица стилей печати .plot-table-attached-to = Стол для игровых участков, прикрепленный .plot-table-type = Тип стола для игровых автоматов + .color-dependent-plot-styles = Цветозависимые стили печати + .named-plot-styles = Именованные стили печати + .plot-style-is-locked-to-color-in-color-dependent = В цветозависимом режиме (CTB) стиль печати определяется цветом .point-1-x = Точка 1 X .point-1-y = Точка 1 Y .point-1-z = Точка 1 Z diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 5c3f9ff4..32ac287e 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Çizim .annotate = Açıklama .insert = Ekle - .model = Model + .model = Modelleme .layout = Yerleşim .manage = Yönet .view = Görünüm @@ -319,6 +319,7 @@ common = .brightness = Parlaklık .by-layer = Katmandan .byblock = Bloktan + .bycolor = Renge göre .bylayer = Katmandan .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: __ocs_fmt_0__ katı yüzeye dönüştürüldü. .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: bu oturumda oluşturulmuş bir katı seçin. @@ -1253,6 +1254,8 @@ draw = .donut-specify-center-of-donut-enter-to-exit = DONUT Halkanın merkezini belirtin (çıkmak için Enter): .donut-specify-inside-diameter-0 = DONUT İç çapı belirtin <0>: .donut-specify-outside-diameter = DONUT Dış çapı belirtin: + .donut-specify-second-point-for-inside-diameter = DONUT İç çap için ikinci noktayı belirtin: + .donut-specify-second-point-for-outside-diameter = DONUT Dış çap için ikinci noktayı belirtin: .draw-manually = Elle çiz .ellipse-specify-center = ELLIPSE Merkezi belirtin: .ellipse-specify-major-axis-endpoint = ELLIPSE Büyük eksen uç noktasını belirtin: @@ -1807,14 +1810,24 @@ annotate = .collect-leaders = Kılavuzları Topla .ddedit-select-text-entity = DDEDIT Metin nesnesini seçin: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED Ölçü metnini girin (boş = ölçülen değer): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Ölçü çizgisinin konumunu belirtin [Metin/Açı]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED Birinci uzatma çizgisinin başlangıcını belirtin: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED İkinci uzatma çizgisinin başlangıcını belirtin [Metin/Açı]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED Biçimlendirilmiş ölçü metnini girin (boş = ölçülen değer): + .dimaligned-select-object-to-dimension = DIMALIGNED Ölçülendirilecek nesneyi seçin: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED Ölçü çizgisinin konumunu belirtin [Mtext/Metin/Açı]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED Birinci uzatma çizgisinin başlangıcını belirtin veya nesne seçmek için Enter'a basın: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED İkinci uzatma çizgisinin başlangıcını belirtin: .dimaligned-specify-text-angle-degrees = DIMALIGNED Metin açısını belirtin (derece): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR Ölçü metnini girin (boş = ölçülen değer): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR Biçimlendirilmiş ölçü metnini girin (boş = ölçülen değer): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR Yay, çember veya çizgi seçin ya da açı köşesi belirtin: + .dimangular-select-second-line = DIMANGULAR İkinci çizgiyi seçin: .dimangular-specify-angle-vertex = DIMANGULAR Açı köşesini belirtin: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR Ölçü yayının konumunu belirtin [Mtext/Metin/Açı/Kadran]: .dimangular-specify-dimension-arc-location = DIMANGULAR Ölçü yayının konumunu belirtin: .dimangular-specify-first-extension-line-point = DIMANGULAR Birinci uzatma çizgisi noktasını belirtin: + .dimangular-specify-quadrant = DIMANGULAR Kadranı belirtin: .dimangular-specify-second-extension-line-point = DIMANGULAR İkinci uzatma çizgisi noktasını belirtin: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR Metin açısının ikinci noktasını belirtin: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR Metin açısını veya ilk noktayı belirtin: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE Temel ölçü bulunamadı. Önce bir ölçü yerleştirin. .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE İkinci uzatma çizgisinin başlangıcını belirtin (çıkmak için Enter): .dimbreak-select-dimension-to-break = DIMBREAK Kesilecek ölçüyü seçin: @@ -1822,8 +1835,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE Temel ölçü bulunamadı. Önce bir ölçü yerleştirin. .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE İkinci uzatma çizgisinin başlangıcını belirtin (çıkmak için Enter): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER Ölçü metnini girin (boş = ölçülen değer): - .dimdiameter-specify-center-point = DIMDIAMETER Merkez noktasını belirtin: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER Ölçü çizgisinin konumunu belirtin [Metin/Açı]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER Yay, daire veya çoklu çizgi yayı seçin: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER Ölçü çizgisinin konumunu belirtin [Çok satırlı metin/Metin/Açı]: .dimdiameter-specify-point-on-circle = DIMDIAMETER Daire üzerindeki noktayı belirtin: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER Metin açısını belirtin (derece): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT Metin geçersiz kılmasını girin (boş = ölçülen değere sıfırla): @@ -1831,6 +1844,12 @@ annotate = .dimjogged-created-a-jogged-radius-dimension = DIMJOGGED: kırıklı yarıçap ölçüsü oluşturuldu. .dimjogged-invalid-radius = DIMJOGGED: yarıçap geçersiz. .dimjogged-select-an-arc-or-circle-first = DIMJOGGED: önce bir yay veya daire seçin. + .dimjogged-enter-dimension-text-blank-measured = DIMJOGGED Ölçü metnini girin (boş = ölçülen değer): + .dimjogged-select-arc-circle-or-polyline-arc = DIMJOGGED Yay, daire veya çoklu çizgi yayı seçin: + .dimjogged-specify-center-location-override = DIMJOGGED Geçersiz kılınmış merkez konumunu belirtin: + .dimjogged-specify-dimension-line-location-text = DIMJOGGED Ölçü çizgisinin konumunu belirtin [Çok satırlı metin/Metin/Açı]: + .dimjogged-specify-jog-location = DIMJOGGED Kırılma konumunu belirtin: + .dimjogged-specify-text-angle-degrees = DIMJOGGED Metin açısını belirtin (derece): .dimjogline-select-linear-or-aligned-dimension = DIMJOGLINE Doğrusal veya hizalı ölçüyü seçin: .dimjogline-specify-jog-location = DIMJOGLINE Kırılma konumunu belirtin: .dimlinear-enter-dimension-text-blank-measured = DIMLINEAR Ölçü metnini girin (boş = ölçülen değer): @@ -1839,10 +1858,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR İkinci uzatma çizgisinin başlangıcını belirtin [Metin/Açı]: .dimlinear-specify-text-angle-degrees = DIMLINEAR Metin açısını belirtin (derece): .dimordinate-specify-feature-location = DIMORDINATE Unsur konumunu belirtin: - .dimordinate-specify-leader-endpoint = DIMORDINATE Kılavuzun uç noktasını belirtin: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE Ölçü metnini girin (boş = ölçülen değer): + .dimordinate-specify-leader-endpoint = DIMORDINATE Kılavuzun uç noktasını belirtin [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE Metin açısını belirtin (derece): .dimradius-enter-dimension-text-blank-measured = DIMRADIUS Ölçü metnini girin (boş = ölçülen değer): - .dimradius-specify-center-point = DIMRADIUS Merkez noktasını belirtin: - .dimradius-specify-dimension-line-location-text = DIMRADIUS Ölçü çizgisinin konumunu belirtin [Metin/Açı]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Yay, daire veya çoklu çizgi yayı seçin: + .dimradius-specify-dimension-line-location-text = DIMRADIUS Ölçü çizgisinin konumunu belirtin [Çok satırlı metin/Metin/Açı]: .dimradius-specify-radius-point = DIMRADIUS Yarıçap noktasını belirtin: .dimradius-specify-text-angle-degrees = DIMRADIUS Metin açısını belirtin (derece): .dimspace-enter-value-0-auto = DIMSPACE Değeri girin (0 = otomatik): @@ -1870,6 +1891,7 @@ annotate = .link-data = Verileri Bağla .lower = Alt .mleader-specify-arrowhead-point = MLEADER Ok ucu noktasını belirtin: + .mleader-specify-landing-point = MLEADER Yatay bağlantı noktasını belirtin: .mleader-specify-next-point-count-pts-enter-to = MLEADER Sonraki noktayı belirtin [__ocs_arg_count__ nokta — metni yerleştirmek için Enter]: .mleaderadd-select-a-multileader = MLEADERADD Bir çoklu kılavuz seçin: .mleaderadd-specify-arrowhead-location = MLEADERADD Ok ucu konumunu belirtin: @@ -1901,8 +1923,13 @@ annotate = .scale-list = Ölçek Listesi .sentence = Cümle Düzeni .sync-scales = Ölçekleri Eşitle + .table-cell-enter-text-row-column-current = TABLE CELL [__ocs_arg_row__,__ocs_arg_column__] hücresi için metni girin <__ocs_arg_current__>: + .table-enter-column-width-width = TABLE Sütun genişliğini girin [__ocs_arg_width__]: .table-enter-number-of-columns-cols = TABLE Sütun sayısını girin [__ocs_arg_cols__]: + .table-enter-number-of-data-rows-rows-cols = TABLE Veri satırı sayısını girin [__ocs_arg_rows__] (__ocs_arg_cols__ sütun): .table-enter-number-of-rows-incl-header-rows-co = TABLE Satır sayısını girin (başlık dahil) [__ocs_arg_rows__] (__ocs_arg_cols__ sütun): + .table-enter-row-height-height = TABLE Satır yüksekliğini girin [__ocs_arg_height__]: + .table-insertion-behavior-point-window-mode = TABLE Ekleme yöntemi [Nokta/Pencere] <__ocs_arg_mode__>: .table-specify-insertion-point-cols-rows = TABLE Ekleme noktasını belirtin [__ocs_arg_cols__×__ocs_arg_rows__]: .tcase-upper-lower-sentence-title = TCASE [Büyük Harf / Küçük Harf / Cümle Düzeni / Başlık Düzeni]: .tcase-select-text-objects-first = TCASE: Önce metin nesnelerini seçin. @@ -2023,6 +2050,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ nesne panoya kesildi. model = + .create = Oluştur + .boolean = Boole işlemleri + .pyramid = Piramit + .polysolid = Çoklu katı + .extrude = Katılaştır + .revolve = Döndür + .loft = Kesitler arası + .sweep = Yol boyunca süpür + .presspull = İt/Çek .n-specify-center-point = __ocs_arg_n__ Merkez noktasını belirtin: .n-specify-first-corner = __ocs_arg_n__ İlk köşeyi belirtin: .n-specify-height-enter-for-default = __ocs_arg_n__ Yüksekliği belirtin : @@ -2166,6 +2202,7 @@ view = .color-scheme-set-to-name = Renk düzeni '__ocs_fmt_0__' olarak ayarlandı. .current-annotation-scale-name = Geçerli açıklama ölçeği: __ocs_fmt_0__ .datalink-path-to-the-csv-file = DATALINK .csv dosyasının yolu: + .datalink-specify-insertion-point = DATALINK Ekleme noktasını belirtin: .datalink-cannot-read-path-e = DATALINK: "__ocs_fmt_0__" okunamıyor: __ocs_fmt_1__ .datalink-imported-nrows-ncols-cells-into-a-tab = DATALINK: __ocs_fmt_0__×__ocs_fmt_1__ hücre, başlangıç noktasındaki tabloya aktarıldı. .datalink-the-csv-file-is-empty = DATALINK: CSV dosyası boş. @@ -2234,6 +2271,11 @@ view = .shortcut-set-key-cmd-str = Kısayol ayarlandı: __ocs_fmt_0__ → __ocs_fmt_1__ .table-cell-select-a-table-entity-first-or-row = TABLE CELL: Önce bir Tablo nesnesi seçin ya da satır/sütun aralık dışında. .table-cell-set-row-col-text = TABLE CELL: [__ocs_fmt_0__,__ocs_fmt_1__] = "__ocs_fmt_2__" olarak ayarlandı. + .table-block-definition-not-found = TABLE: Blok tanımı bulunamadı. + .table-edit-applied = TABLE: Düzenleme uygulandı. + .table-field-handle-not-found = TABLE: Alan tanıtıcısı bulunamadı. + .table-row-column-out-of-range-or-cell-locked = TABLE: Satır/sütun aralık dışında veya hücre kilitli. + .table-select-a-table-first = TABLE: Önce bir tablo seçin. .toolpalettes-tool-palettes-not-yet-implemented = TOOLPALETTES: Araç Paletleri henüz uygulanmadı. .tile-horiz = Yatay Döşe .tile-vert = Dikey Döşe @@ -2270,6 +2312,7 @@ view = .usage-shortcuts-list-set-key-cmd-clear-key = Kullanım: SHORTCUTS LIST | SET | CLEAR .usage-shortcuts-set-key-command-e-g-shortcuts = Kullanım: SHORTCUTS SET ör. SHORTCUTS SET CTRL+D DIST .usage-table-cell-row-col-text = Kullanım: TABLE CELL + .usage-table-edit-actions = Kullanım: TABLE CELL | INSERTROW | DELETEROW | INSERTCOL | DELETECOL | MERGE | UNMERGE | ROWHEIGHT | COLWIDTH | LOCK | BLOCK | FORMULA | FIELD .usage-ucsicon-on-off-noorigin-origin = Kullanım: UCSICON ON | OFF | NOORIGIN | ORIGIN .usage-underlay-fade-n-contrast-n-on-off-clip-o = Kullanım: UNDERLAY [FADE |CONTRAST |ON|OFF|CLIP ON|OFF|MONO ON|OFF] .usage-useri-1-5-int-userr-1-5-real = Kullanım: USERI <1-5> | USERR <1-5> @@ -2303,6 +2346,7 @@ view = .zoom-window-specify-first-corner = ZOOM WINDOW İlk köşeyi belirtin: .zoom-window-specify-opposite-corner = ZOOM WINDOW Karşı köşeyi belirtin: .zoom-extents = Sınırlara Yakınlaştır + .cmd-no-hatch-objects = __ocs_fmt_0__: Tarama nesnesi yok. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: Metin veya ölçü nesnesi yok. .cmd-not-yet-implemented = __ocs_fmt_0__: Henüz uygulanmadı. .viewport-s-in-layout = "__ocs_fmt_1__" yerleşiminde __ocs_fmt_0__ görünüm alanı: @@ -2568,6 +2612,7 @@ properties = .island-detection-style = Ada algılama stili .italic = İtalik .jog-angle = Kırılma Açısı + .jogged-radius = Kırıklı Yarıçap .jog-x = Kırılma X .jog-y = Kırılma Y .jog-z = Kırılma Z @@ -2698,6 +2743,9 @@ properties = .plot-style-table = Çizim stili tablosu .plot-table-attached-to = Çizim tablosunun eklendiği .plot-table-type = Çizim tablosu türü + .color-dependent-plot-styles = Renge bağlı çizim stilleri + .named-plot-styles = Adlandırılmış çizim stilleri + .plot-style-is-locked-to-color-in-color-dependent = Renge Bağlı (CTB) modunda çizim stili renge kilitlidir .point-1-x = Nokta 1 X .point-1-y = Nokta 1 Y .point-1-z = Nokta 1 Z diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 3440c473..c2ec0095 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = 绘制 .annotate = 注释 .insert = 插入 - .model = 模型 + .model = 建模 .layout = 布局 .manage = 管理 .view = 视图 @@ -320,6 +320,7 @@ common = .brightness = 亮度 .by-layer = 按层 .byblock = 按块 + .bycolor = 按颜色 .bylayer = 随层 .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE:将__ocs_fmt_0__固态转换为地表. .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE:选择创建此会话的固件 。 @@ -1786,14 +1787,24 @@ annotate = .collect-leaders = 合并引线 .ddedit-select-text-entity = DDEDIT 选择文字实体: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED 输入标注文字(空白 = 测量值): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED 指定尺寸线位置 [文字/角度]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED 指定第一条尺寸界线原点: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED 指定第二条尺寸界线原点 [文字/角度]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED 输入带格式的标注文字(空白 = 测量值): + .dimaligned-select-object-to-dimension = DIMALIGNED 选择要标注的对象: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED 指定尺寸线位置 [Mtext/文字/角度]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED 指定第一条尺寸界线原点,或按 Enter 选择对象: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED 指定第二条尺寸界线原点: .dimaligned-specify-text-angle-degrees = DIMALIGNED 指定文字角度(度): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR 输入标注文字(空白 = 测量值): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR 输入带格式的标注文字(空白 = 测量值): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR 选择圆弧、圆或直线,或指定角度顶点: + .dimangular-select-second-line = DIMANGULAR 选择第二条直线: .dimangular-specify-angle-vertex = DIMANGULAR 指定角度顶点: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR 指定标注圆弧位置 [Mtext/文字/角度/象限]: .dimangular-specify-dimension-arc-location = DIMANGULAR 指定标注圆弧位置: .dimangular-specify-first-extension-line-point = DIMANGULAR 指定第一条尺寸界线点: + .dimangular-specify-quadrant = DIMANGULAR 指定象限: .dimangular-specify-second-extension-line-point = DIMANGULAR 指定第二条尺寸界线点: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR 指定文字角度的第二点: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR 指定文字角度或第一点: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE 未找到基准标注。请先放置标注。 .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE 指定第二条尺寸界线原点(按 Enter 退出): .dimbreak-select-dimension-to-break = DIMBREAK 选择要打断的标注: @@ -1801,8 +1812,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE 未找到基准标注。请先放置标注。 .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE 指定第二条尺寸界线原点(按 Enter 退出): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER 输入标注文字(空白 = 测量值): - .dimdiameter-specify-center-point = DIMDIAMETER 指定中心点: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER 指定尺寸线位置 [文字/角度]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER 选择圆弧、圆或多段线圆弧: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER 指定尺寸线位置 [多行文字/文字/角度]: .dimdiameter-specify-point-on-circle = DIMDIAMETER 指定圆上的点: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER 指定文字角度(度): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT 输入文字替代(空白 = 重置为测量值): @@ -1818,10 +1829,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR 指定第二条尺寸界线原点 [文字/角度]: .dimlinear-specify-text-angle-degrees = DIMLINEAR 指定文字角度(度): .dimordinate-specify-feature-location = DIMORDINATE 指定特征位置: - .dimordinate-specify-leader-endpoint = DIMORDINATE 指定引线端点: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE 输入标注文字(空白 = 测量值): + .dimordinate-specify-leader-endpoint = DIMORDINATE 指定引线端点 [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE 指定文字角度(度): .dimradius-enter-dimension-text-blank-measured = DIMRADIUS 输入标注文字(空白 = 测量值): - .dimradius-specify-center-point = DIMRADIUS 指定中心点: - .dimradius-specify-dimension-line-location-text = DIMRADIUS 指定尺寸线位置 [文字/角度]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS 选择圆弧、圆或多段线圆弧: + .dimradius-specify-dimension-line-location-text = DIMRADIUS 指定尺寸线位置 [多行文字/文字/角度]: .dimradius-specify-radius-point = DIMRADIUS 指定半径点: .dimradius-specify-text-angle-degrees = DIMRADIUS 指定文字角度(度): .dimspace-enter-value-0-auto = DIMSPACE 输入值(0 = 自动): @@ -1853,6 +1866,7 @@ annotate = 数据 .lower = 下调 .mleader-specify-arrowhead-point = MLEADER 指定箭头点: + .mleader-specify-landing-point = MLEADER 指定水平基线点: .mleader-specify-next-point-count-pts-enter-to = MLEADER 指定下一点 [__ocs_arg_count__ 个点 — 按 Enter 放置文字]: .mleaderadd-select-a-multileader = MLEADERADD 选择多重引线: .mleaderadd-specify-arrowhead-location = MLEADERADD 指定箭头位置: @@ -2018,6 +2032,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ 对象切入剪贴板。 model = + .create = 创建 + .boolean = 布尔运算 + .pyramid = 棱锥 + .polysolid = 多段体 + .extrude = 拉伸 + .revolve = 旋转 + .loft = 放样 + .sweep = 扫掠 + .presspull = 按拉 .n-specify-center-point = __ocs_arg_n__ 指定中心点: .n-specify-first-corner = __ocs_arg_n__ 指定第一个角点: .n-specify-height-enter-for-default = __ocs_arg_n__ 指定高度(按 Enter 使用默认值): @@ -2318,6 +2341,7 @@ view = .zoom-extents = 缩放 范围 + .cmd-no-hatch-objects = __ocs_fmt_0__:µ▓íµ£ëÕø¥µíêÕí½ÕààÕ»╣Þ▒íÒÇé .cmd-no-text-or-dimension-objects = __ocs_fmt_0__:没有文本或维度对象。 .cmd-not-yet-implemented = __ocs_fmt_0__:尚未执行。 .viewport-s-in-layout = 布局“__ocs_fmt_1__”中的视口(__ocs_fmt_0__ 个): @@ -2695,6 +2719,9 @@ properties = .plot-style-table = 打印样式表 .plot-table-attached-to = 打印样式表应用于 .plot-table-type = 打印样式表类型 + .color-dependent-plot-styles = 颜色相关打印样式 + .named-plot-styles = 命名打印样式 + .plot-style-is-locked-to-color-in-color-dependent = 在颜色相关 (CTB) 模式下,打印样式由颜色决定 .point-1-x = 点 1 X .point-1-y = 点 1 Y .point-1-z = 点 1 Z diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index fb781a28..013d4aca 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = 繪製 .annotate = 註解 .insert = 插入 - .model = 模型 + .model = 建模 .layout = 配置 .manage = 管理 .view = 視圖 @@ -332,6 +332,7 @@ common = .brightness = 亮度 .by-layer = 按層 .byblock = 依圖塊 + .bycolor = 依色彩 .bylayer = 隨層 .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE:將__ocs_fmt_0__實體轉換為曲面. .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE:選擇建立此會話的固件 。 @@ -1798,14 +1799,24 @@ annotate = .collect-leaders = 合併引線 .ddedit-select-text-entity = DDEDIT 選擇文字實體: .dimaligned-enter-dimension-text-blank-measured = DIMALIGNED 輸入標註文字(空白 = 測量值): - .dimaligned-specify-dimension-line-location-tex = DIMALIGNED 指定尺寸線位置 [文字/角度]: - .dimaligned-specify-first-extension-line-origin = DIMALIGNED 指定第一條尺寸界線原點: - .dimaligned-specify-second-extension-line-origi = DIMALIGNED 指定第二條尺寸界線原點 [文字/角度]: + .dimaligned-enter-formatted-dimension-text-blank-measured = DIMALIGNED 輸入帶格式的標註文字(空白 = 測量值): + .dimaligned-select-object-to-dimension = DIMALIGNED 選取要標註的物件: + .dimaligned-specify-dimension-line-location-tex = DIMALIGNED 指定尺寸線位置 [Mtext/文字/角度]: + .dimaligned-specify-first-extension-line-origin = DIMALIGNED 指定第一條尺寸界線原點,或按 Enter 選取物件: + .dimaligned-specify-second-extension-line-origi = DIMALIGNED 指定第二條尺寸界線原點: .dimaligned-specify-text-angle-degrees = DIMALIGNED 指定文字角度(度): + .dimangular-enter-dimension-text-blank-measured = DIMANGULAR 輸入標註文字(空白 = 測量值): + .dimangular-enter-formatted-dimension-text-blank-measured = DIMANGULAR 輸入帶格式的標註文字(空白 = 測量值): + .dimangular-select-arc-circle-line-or-specify-angle-vertex = DIMANGULAR 選取圓弧、圓或直線,或指定角度頂點: + .dimangular-select-second-line = DIMANGULAR 選取第二條直線: .dimangular-specify-angle-vertex = DIMANGULAR 指定角度頂點: + .dimangular-specify-dimension-arc-location-options = DIMANGULAR 指定標註圓弧位置 [Mtext/文字/角度/象限]: .dimangular-specify-dimension-arc-location = DIMANGULAR 指定標註圓弧位置: .dimangular-specify-first-extension-line-point = DIMANGULAR 指定第一條尺寸界線點: + .dimangular-specify-quadrant = DIMANGULAR 指定象限: .dimangular-specify-second-extension-line-point = DIMANGULAR 指定第二條尺寸界線點: + .dimangular-specify-second-point-for-text-angle = DIMANGULAR 指定文字角度的第二點: + .dimangular-specify-text-angle-or-first-point = DIMANGULAR 指定文字角度或第一點: .dimbaseline-no-base-dimension-found-place-a-di = DIMBASELINE 未找到基準標註。請先放置標註。 .dimbaseline-specify-a-second-extension-line-or = DIMBASELINE 指定第二條尺寸界線原點(按 Enter 退出): .dimbreak-select-dimension-to-break = DIMBREAK 選擇要打斷的標註: @@ -1813,8 +1824,8 @@ annotate = .dimcontinue-no-base-dimension-found-place-a-di = DIMCONTINUE 未找到基準標註。請先放置標註。 .dimcontinue-specify-a-second-extension-line-or = DIMCONTINUE 指定第二條尺寸界線原點(按 Enter 退出): .dimdiameter-enter-dimension-text-blank-measure = DIMDIAMETER 輸入標註文字(空白 = 測量值): - .dimdiameter-specify-center-point = DIMDIAMETER 指定中心點: - .dimdiameter-specify-dimension-line-location-te = DIMDIAMETER 指定尺寸線位置 [文字/角度]: + .dimdiameter-select-arc-circle-or-polyline-arc = DIMDIAMETER 選擇圓弧、圓或聚合線圓弧: + .dimdiameter-specify-dimension-line-location-text = DIMDIAMETER 指定尺寸線位置 [多行文字/文字/角度]: .dimdiameter-specify-point-on-circle = DIMDIAMETER 指定圓上的點: .dimdiameter-specify-text-angle-degrees = DIMDIAMETER 指定文字角度(度): .dimedit-enter-text-override-blank-reset-to-mea = DIMEDIT 輸入文字替代(空白 = 重置為測量值): @@ -1830,10 +1841,12 @@ annotate = .dimlinear-specify-second-extension-line-origin = DIMLINEAR 指定第二條尺寸界線原點 [文字/角度]: .dimlinear-specify-text-angle-degrees = DIMLINEAR 指定文字角度(度): .dimordinate-specify-feature-location = DIMORDINATE 指定特徵位置: - .dimordinate-specify-leader-endpoint = DIMORDINATE 指定引線端點: + .dimordinate-enter-dimension-text-blank-measured = DIMORDINATE 輸入標註文字(空白 = 測量值): + .dimordinate-specify-leader-endpoint = DIMORDINATE 指定引線端點 [Xdatum/Ydatum/Mtext/Text/Angle]: + .dimordinate-specify-text-angle-degrees = DIMORDINATE 指定文字角度(度): .dimradius-enter-dimension-text-blank-measured = DIMRADIUS 輸入標註文字(空白 = 測量值): - .dimradius-specify-center-point = DIMRADIUS 指定中心點: - .dimradius-specify-dimension-line-location-text = DIMRADIUS 指定尺寸線位置 [文字/角度]: + .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS 選擇圓弧、圓或聚合線圓弧: + .dimradius-specify-dimension-line-location-text = DIMRADIUS 指定尺寸線位置 [多行文字/文字/角度]: .dimradius-specify-radius-point = DIMRADIUS 指定半徑點: .dimradius-specify-text-angle-degrees = DIMRADIUS 指定文字角度(度): .dimspace-enter-value-0-auto = DIMSPACE 輸入值(0 = 自動): @@ -1865,6 +1878,7 @@ annotate = 資料 .lower = 下調 .mleader-specify-arrowhead-point = MLEADER 指定箭頭點: + .mleader-specify-landing-point = MLEADER 指定水平基線點: .mleader-specify-next-point-count-pts-enter-to = MLEADER 指定下一點 [__ocs_arg_count__ 個點 — 按 Enter 放置文字]: .mleaderadd-select-a-multileader = MLEADERADD 選擇多重引線: .mleaderadd-specify-arrowhead-location = MLEADERADD 指定箭頭位置: @@ -2030,6 +2044,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ 物件切入剪貼簿。 model = + .create = 建立 + .boolean = 布林運算 + .pyramid = 角錐體 + .polysolid = 多段實體 + .extrude = 擠出 + .revolve = 旋轉 + .loft = 放樣 + .sweep = 掃掠 + .presspull = 按拉 .n-specify-center-point = __ocs_arg_n__ 指定中心點: .n-specify-first-corner = __ocs_arg_n__ 指定第一個角點: .n-specify-height-enter-for-default = __ocs_arg_n__ 指定高度(按 Enter 使用預設值): @@ -2330,6 +2353,7 @@ view = .zoom-extents = 縮放 範圍 + .cmd-no-hatch-objects = __ocs_fmt_0__:µ▓Ƶ£ëÕí½ÕààþÀÜþë®õ╗ÂÒÇé .cmd-no-text-or-dimension-objects = __ocs_fmt_0__:沒有文字或維度物件。 .cmd-not-yet-implemented = __ocs_fmt_0__:尚未實作。 .viewport-s-in-layout = 配置“__ocs_fmt_1__”中的視口(__ocs_fmt_0__ 個): @@ -2707,6 +2731,9 @@ properties = .plot-style-table = 列印樣式表 .plot-table-attached-to = 列印樣式表應用於 .plot-table-type = 列印樣式表類型 + .color-dependent-plot-styles = 色彩相關出圖型式 + .named-plot-styles = 具名出圖型式 + .plot-style-is-locked-to-color-in-color-dependent = 在色彩相關 (CTB) 模式下,出圖型式由色彩決定 .point-1-x = 點 1 X .point-1-y = 點 1 Y .point-1-z = 點 1 Z diff --git a/plugins/registry.json b/plugins/registry.json index 5a1bc54f..a2e4fe8b 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -18,5 +18,10 @@ "repo": "KevinGriffin-new/opencad-landsurvey-plugin", "name": "Land Survey", "description": "Survey points, PNEZD & LandXML import, TIN surfaces, earthwork volumes, COGO, and coordinate transforms (RTS / Helmert / resection)." + }, + { + "repo": "piyton/ocs-mcp-bridge", + "name": "MCP Bridge", + "description": "Read/write bridge for external tools: serves the live selection and accepts draw/remove requests over a local socket (MCP servers, scripts)." } ] diff --git a/src/app/alias.rs b/src/app/alias.rs index fc305430..d9c3aeb0 100644 --- a/src/app/alias.rs +++ b/src/app/alias.rs @@ -73,8 +73,9 @@ const WEB_ALIAS_KEY: &str = "opencadstudio.aliases"; /// Version of the shipped default alias table. Bump this whenever the embedded /// defaults add aliases or change a default target, so existing profiles can be /// migrated forward (see `introduced_at` / `migrate_aliases`). Version 1 shipped -/// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases. -const DEFAULT_ALIASES_VERSION: u32 = 2; +/// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases; version 3 +/// introduced HB (HATCHTOBACK). +const DEFAULT_ALIASES_VERSION: u32 = 3; #[cfg(target_arch = "wasm32")] const WEB_ALIAS_VERSION_KEY: &str = "opencadstudio.aliases.version"; @@ -91,6 +92,7 @@ fn introduced_at(version: u32) -> &'static [(&'static str, &'static str)] { ("RE", "REGEN"), ("REA", "REGENALL"), ], + 3 => &[("HB", "HATCHTOBACK")], _ => &[], } } @@ -330,17 +332,18 @@ mod tests { .collect() } - /// An existing profile that predates the REDRAW aliases gains exactly the + /// An existing profile that predates the REDRAW and HB aliases gains exactly the /// new defaults, with every pre-existing mapping kept. #[test] fn migration_adds_new_defaults_preserving_existing() { let mut stored = map(&[("L", "LINE"), ("Z", "ZOOM"), ("HI", "HIDE")]); let changed = migrate_aliases(&mut stored, 0); - assert!(changed, "migrating from v0 to v2 must add defaults"); + assert!(changed, "migrating from v0 to v3 must add defaults"); assert_eq!(stored.get("R").map(String::as_str), Some("REDRAW")); assert_eq!(stored.get("RA").map(String::as_str), Some("REDRAWALL")); assert_eq!(stored.get("RE").map(String::as_str), Some("REGEN")); assert_eq!(stored.get("REA").map(String::as_str), Some("REGENALL")); + assert_eq!(stored.get("HB").map(String::as_str), Some("HATCHTOBACK")); // Existing entries must survive untouched. assert_eq!(stored.get("L").map(String::as_str), Some("LINE")); assert_eq!(stored.get("Z").map(String::as_str), Some("ZOOM")); @@ -375,10 +378,10 @@ mod tests { assert_eq!(stored.get("L").map(String::as_str), Some("LINE")); } - /// Migrating a mid-range profile (one that saw v1) only pulls in v2's + /// Migrating a mid-range profile (one that saw v1) pulls in v2's and v3's /// additions, not hypothetical earlier ones. #[test] - fn migration_from_version_one_adds_v2_aliases() { + fn migration_from_version_one_adds_v2_and_v3_aliases() { let mut stored = map(&[("L", "LINE")]); let changed = migrate_aliases(&mut stored, 1); assert!(changed); @@ -386,5 +389,16 @@ mod tests { assert_eq!(stored.get("RA").map(String::as_str), Some("REDRAWALL")); assert_eq!(stored.get("RE").map(String::as_str), Some("REGEN")); assert_eq!(stored.get("REA").map(String::as_str), Some("REGENALL")); + assert_eq!(stored.get("HB").map(String::as_str), Some("HATCHTOBACK")); + } + + /// Migrating a profile that saw v2 pulls in v3's HB addition only. + #[test] + fn migration_from_version_two_adds_v3_aliases() { + let mut stored = map(&[("L", "LINE")]); + let changed = migrate_aliases(&mut stored, 2); + assert!(changed); + assert_eq!(stored.get("R"), None); + assert_eq!(stored.get("HB").map(String::as_str), Some("HATCHTOBACK")); } } diff --git a/src/app/automation.rs b/src/app/automation.rs index f72c3048..fddac688 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -193,6 +193,17 @@ fn entity_json(e: &acadrust::EntityType) -> Value { impl OpenCADStudio { /// Handle one JSON request line and return the JSON response. pub(crate) fn automation_op(&mut self, line: &str) -> Value { + let res = self.automation_op_inner(line); + // Most automation ops mutate `Scene::selected` directly rather than + // going through `update()` (`select` calls `deselect_all` / + // `select_entity`, and `run` can erase the selected entities), so the + // selection check has to run on this path too. + #[cfg(not(target_arch = "wasm32"))] + self.notify_plugins_selection_changed(); + res + } + + fn automation_op_inner(&mut self, line: &str) -> Value { let req: Value = match serde_json::from_str(line) { Ok(v) => v, Err(e) => return err(format!("invalid JSON: {e}")), @@ -201,6 +212,7 @@ impl OpenCADStudio { "new" => { let i = self.active_tab; self.tabs[i].scene.document = acadrust::CadDocument::new(); + self.tabs[i].scene.deselect_all(); self.tabs[i].current_path = None; // The headless session starts on the welcome (Start) tab, which // blocks drawing commands; turn it into a real drawing. @@ -224,6 +236,7 @@ impl OpenCADStudio { Ok(doc) => { let i = self.active_tab; self.tabs[i].scene.document = doc; + self.tabs[i].scene.deselect_all(); crate::app::style_ops::ensure_standard_styles( &mut self.tabs[i].scene.document, ); diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index f1924bc7..09eb0971 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -581,6 +581,36 @@ impl OpenCADStudio { let _ = self.apply_cmd_result(r); return; } + let accepts_points = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.entity_pick_accepts_points()); + if accepts_points { + if let Some((coord, kind)) = super::helpers::parse_coord(token) { + let ucs = self.tabs[i].active_ucs.clone(); + let wcs = match ( + matches!(kind, super::helpers::CoordKind::Relative), + self.last_point, + ) { + (true, Some(base)) => { + base + match &ucs { + Some(value) => super::helpers::ucs_rotate_vec(coord, value), + None => coord, + } + } + _ => match &ucs { + Some(value) => super::helpers::ucs_to_wcs(coord, value), + None => coord, + }, + }; + if self.command_point_allowed(i, wcs) { + self.last_point = Some(wcs); + self.push_ucs_to_cmd(i); + let _ = self.feed_command(StepInput::Point(wcs)); + } + return; + } + } if let Ok(v) = u64::from_str_radix(token.trim_start_matches("0x"), 16) { let handle = Handle::new(v); let pt = self.tabs[i] @@ -683,8 +713,12 @@ impl OpenCADStudio { } } let was_active = self.tabs[self.active_tab].active_cmd.is_some(); - let preserve_selection = - matches!(result, CmdResult::Relaunch(..) | CmdResult::Dispatch(..)); + let preserve_selection = matches!( + &result, + CmdResult::Relaunch(..) + | CmdResult::Dispatch(..) + | CmdResult::SolidSubtract { .. } + ); let task = self.apply_cmd_result_inner(result); let i = self.active_tab; let preview_hidden = self.tabs[i] @@ -711,6 +745,7 @@ impl OpenCADStudio { // so CLAYER / CECOLOR / … are left unchanged (#239). No-op otherwise. if was_active && self.tabs[i].active_cmd.is_none() { self.tabs[i].scene.set_hover_highlight(None); + self.command_line.set_step_options(Vec::new()); self.restore_add_selected_defaults(); } task @@ -729,6 +764,7 @@ impl OpenCADStudio { .and_then(|c| c.attedit_pending_handle()); if let Some(ins_handle) = attedit_handle { self.tabs[i].active_cmd = None; + self.command_line.set_step_options(Vec::new()); self.open_attribute_editor(ins_handle); return Task::none(); } @@ -736,6 +772,12 @@ impl OpenCADStudio { if let Some(p) = prompt { self.command_line.push_info(&p); } + let opts = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.options()) + .unwrap_or_default(); + self.command_line.set_step_options(opts); if !self.tabs[i] .active_cmd .as_ref() @@ -797,7 +839,26 @@ impl OpenCADStudio { // the structure snapshot fallback. let delta_safe = self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); - self.commit_entity(entity); + let is_associative_dimension = matches!( + entity, + acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Linear(_) + | acadrust::entities::Dimension::Aligned(_) + ) + ); + let association_enabled = + self.tabs[i].scene.document.header.dimension_associativity == 2; + let committed = self.commit_entity_handle(entity); + if is_associative_dimension && association_enabled { + if let Some(handle) = committed { + let sources = self.tabs[i] + .scene + .infer_dimension_sources(handle); + self.tabs[i] + .scene + .attach_dimension_association(handle, sources.to_vec()); + } + } self.tabs[i].dirty = true; let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { @@ -1181,13 +1242,155 @@ impl OpenCADStudio { // and ribbon dropdowns now, not on the next reopen (#407). self.refresh_layer_panel(); } + let insert_block_name = match &entity { + acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()), + _ => None, + }; // Record where this draw ended so ARC_CONT can continue from it // (before `entity` is moved into commit_entity). self.update_cont_anchor(&entity); let label = self.history_label_from_active_cmd(i, "ENTITY"); let delta_safe = self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); - self.commit_entity(entity); + let is_associative_dimension = matches!( + entity, + acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Linear(_) + | acadrust::entities::Dimension::Aligned(_) + ) + ); + let association_enabled = + self.tabs[i].scene.document.header.dimension_associativity == 2; + let committed = self.commit_entity_handle(entity); + if is_associative_dimension && association_enabled { + if let Some(handle) = committed { + let sources = self.tabs[i] + .scene + .infer_dimension_sources(handle); + self.tabs[i] + .scene + .attach_dimension_association(handle, sources.to_vec()); + } + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + if let Some(name) = insert_block_name { + self.record_block_insert(&name); + } + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + CmdResult::CommitDimension { + mut entity, + association, + } => { + let label = self.history_label_from_active_cmd(i, "DIMENSION"); + let association_mode = self.tabs[i] + .scene + .document + .header + .dimension_associativity; + let single_source_dimension = matches!( + &entity, + acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Ordinate(_) + ) + ); + let pending = if association_mode == 0 { + let layer = self.tabs[i].active_layer.clone(); + if layer != "0" || entity.as_entity().layer().is_empty() { + entity.as_entity_mut().set_layer(layer); + } + crate::scene::view::dispatch::apply_color( + &mut entity, + self.ribbon.active_color, + ); + crate::scene::view::dispatch::apply_common_prop( + &mut entity, + "linetype", + &self.ribbon.active_linetype.clone(), + ); + crate::scene::view::dispatch::apply_line_weight( + &mut entity, + self.ribbon.active_lineweight, + ); + let celtscale = self.tabs[i] + .scene + .document + .header + .current_entity_linetype_scale; + if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 { + entity.common_mut().linetype_scale = celtscale; + } + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + let pieces = crate::modules::draw::modify::explode::explode_entity( + &entity, + &self.tabs[i].scene.document, + ); + let delta_safe = pieces + .iter() + .all(|piece| self.delta_add_safe(i, piece)); + let pending = self.begin_undo(i, label, pieces.len(), delta_safe); + for piece in pieces { + self.tabs[i].scene.add_entity(piece); + } + pending + } else { + let delta_safe = self.delta_add_safe(i, &entity); + let pending = self.begin_undo(i, label, 1, delta_safe); + if let Some(handle) = self.commit_entity_handle(entity) { + if association_mode == 2 { + let mut changes = vec![ + (handle, crate::scene::ChangeKind::Modified), + ]; + match association { + crate::command::DimensionAssociationInput::Infer(source) => { + let sources: Vec<_> = source.map_or_else( + || { + self.tabs[i] + .scene + .infer_dimension_sources(handle) + .into_iter() + .collect() + }, + |source| { + if single_source_dimension { + vec![Some(source)] + } else { + vec![Some(source), Some(source)] + } + }, + ); + self.tabs[i] + .scene + .attach_dimension_association(handle, sources.clone()); + changes.extend(sources.into_iter().flatten().map(|source| { + (source, crate::scene::ChangeKind::Modified) + })); + } + crate::command::DimensionAssociationInput::Explicit(sources) => { + self.tabs[i] + .scene + .attach_dimension_association_sources(handle, sources.clone()); + changes.extend(sources.into_iter().flatten().map(|source| { + (source.handle, crate::scene::ChangeKind::Modified) + })); + } + } + changes.sort_by_key(|(handle, _)| handle.value()); + changes.dedup_by_key(|(handle, _)| handle.value()); + self.tabs[i].scene.bump_entities(&changes); + } + } + pending + }; self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -1204,21 +1407,42 @@ impl OpenCADStudio { } => { let label = self.history_label_from_active_cmd(i, "SOLID"); let pending = self.begin_undo(i, label, 1, true); - self.add_solid_model(entity, *solid, history); - self.tabs[i].dirty = true; + let handle = self.add_solid_model(entity, *solid, history); + if !handle.is_null() { + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } } CmdResult::CommitAndEditText(entity) => { + let annotative_mleader = matches!( + &entity, + acadrust::EntityType::MultiLeader(ml) if ml.enable_annotation_scale + ); let label = self.history_label_from_active_cmd(i, "ENTITY"); - let delta_safe = self.delta_add_safe(i, &entity); + let delta_safe = self.delta_add_safe(i, &entity) && !annotative_mleader; let pending = self.begin_undo(i, label, 1, delta_safe); let handle = self.commit_entity_handle(entity); + + if annotative_mleader { + let scale_handle = self.tabs[i].scene.creation_annotation_scale_handle(); + if let (Some(handle), Some(scale_handle)) = (handle, scale_handle) { + crate::scene::annotative::create_annotation_context( + &mut self.tabs[i].scene.document, + handle, + scale_handle, + ); + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + } + } + self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -1717,6 +1941,10 @@ impl OpenCADStudio { .as_mut() .and_then(|c| c.attreq_take_insert()); if let Some(entity) = entity { + let insert_name = match &entity { + acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()), + _ => None, + }; let label = self.history_label_from_active_cmd(i, "INSERT"); let delta_safe = self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); @@ -1725,15 +1953,21 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; + if let Some(n) = insert_name { + self.record_block_insert(&n); + } self.restore_pre_cmd_tangent(); if let Some(pd) = pending { self.commit_undo_delta(i, pd); } } } else { - // Inject attdefs so the command enters attr-filling mode. - if let Some(cmd) = &mut self.tabs[i].active_cmd { - cmd.attreq_set_attdefs(attdefs); + let completed = self.tabs[i] + .active_cmd + .as_mut() + .and_then(|cmd| cmd.attreq_set_attdefs(attdefs)); + if let Some(entity) = completed { + return self.apply_cmd_result(CmdResult::CommitAndExit(entity)); } let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { @@ -2917,7 +3151,6 @@ impl OpenCADStudio { stretched |= mv(&mut d.definition_point); } Dimension::Ordinate(d) => { - stretched |= mv(&mut d.definition_point); stretched |= mv(&mut d.feature_location); stretched |= mv(&mut d.leader_endpoint); } @@ -2998,80 +3231,32 @@ impl OpenCADStudio { self.commit_undo_delta(i, pending); } } - // ── Solid3D creation (BOX / SPHERE / CYLINDER) ──────────────── - CmdResult::CommitSolid3D { mesh_fn } => { - use crate::modules::insert::solid3d_cmds::empty_solid3d; - let pending = self.begin_undo(i, "SOLID3D", 1, true); - let entity = empty_solid3d(); - let handle = self.tabs[i].scene.add_entity(entity); - if !handle.is_null() { - let name = format!("{}", handle.value()); - let color = [0.6f32, 0.6, 0.8, 1.0]; // default colour; command embedded it - let _ = color; // color is captured inside mesh_fn - if let Some(mesh) = mesh_fn(name) { - let set = crate::scene::MeshLodSet::from_single(mesh); - if let Some(acadrust::EntityType::Solid3D(entity)) = - self.tabs[i].scene.document.get_entity_mut(handle) - { - let center = set.metrics.centroid; - entity.point_of_reference = acadrust::types::Vector3::new( - center[0], center[1], center[2], - ); - } - self.tabs[i].scene.meshes.insert(handle, set); - } - self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("Solid created.").as_ref()); - } - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - } - // ── EXTRUDE ──────────────────────────────────────────────────── CmdResult::ExtrudeEntity { handle, height, - color, + color: _, } => { if self.reject_locked_edit(i, handle) { self.tabs[i].active_cmd = None; return Task::none(); } use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::{solid_model, sweep_model}; + use crate::scene::model::sweep_model; let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned(); if let Some(entity) = entity_opt { - // The kernel sweeps the profile into analytic surfaces — - // a straight run becomes a plane and an arc a cylinder — - // so the solid saves as exact ACIS rather than facets. - let result = sweep_model::extruded(&entity, height as f64) - .and_then(|body| Some((solid_model::mesh_from_solid(&body, color)?, body))); - if let Some((mesh, solid)) = result { - let history = crate::scene::model::solid_history::extrusion_op( - &entity, - height as f64, - ); + if let Some(solid) = sweep_model::extruded(&entity, height) { + let history = crate::scene::model::solid_history::brep_op(&solid); let pending = self.begin_undo(i, "EXTRUDE", 1, true); - let mut s3d = empty_solid3d(); - if let acadrust::EntityType::Solid3D(inner) = &mut s3d { - inner.wires = solid_model::edge_wires(&solid); - } - let new_handle = self.tabs[i].scene.add_entity(s3d); - self.tabs[i] - .scene - .create_solid_history(new_handle, history); - self.tabs[i].scene.register_solid_model(new_handle, solid); - let _ = mesh; - self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("EXTRUDE: solid created.").as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + let created = self.add_solid_model(empty_solid3d(), solid, history); + if !created.is_null() { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("EXTRUDE: solid created.").as_ref()); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } else { self.command_line.push_error(crate::t!("EXTRUDE: could not build profile. Select a closed 2D entity (Circle, LwPolyline, etc.).").as_ref()); @@ -3085,26 +3270,80 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); } + CmdResult::PresspullEntity { + handle, + pick, + distance, + drag, + color, + } => { + if matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Solid3D(_)) + ) { + let task = self.solid_face_presspull(handle, pick, distance, drag); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + return task; + } + if self.reject_locked_edit(i, handle) { + self.tabs[i].active_cmd = None; + return Task::none(); + } + use crate::modules::insert::solid3d_cmds::empty_solid3d; + use crate::scene::model::{solid_history, sweep_model}; + + let result = self.tabs[i].scene.document.get_entity(handle).and_then(|entity| { + let distance = match drag { + Some(point) => sweep_model::projected_drag(entity, pick, point)?, + None => distance, + }; + sweep_model::extruded(entity, distance) + }); + if let Some(solid) = result { + let pending = self.begin_undo(i, "PRESSPULL", 1, true); + let history = solid_history::brep_op(&solid); + let created = self.add_solid_model(empty_solid3d(), solid, history); + let _ = color; + if !created.is_null() { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("PRESSPULL: solid created.").as_ref()); + if let Some(delta) = pending { + self.commit_undo_delta(i, delta); + } + } + } else { + self.command_line.push_error( + crate::t!("PRESSPULL: select a closed profile or planar solid face.") + .as_ref(), + ); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + } + // ── REVOLVE ──────────────────────────────────────────────────── CmdResult::RevolveEntity { handle, axis_start, axis_end, angle_deg, - color, + color: _, } => { if self.reject_locked_edit(i, handle) { self.tabs[i].active_cmd = None; return Task::none(); } use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::{solid_model, sweep_model}; + use crate::scene::model::sweep_model; let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned(); if let Some(entity) = entity_opt { - // A line turned about the axis sweeps into a plane, a - // cylinder or a cone, and an arc into a sphere or a - // torus, so a revolved solid keeps exact geometry too. let result = sweep_model::revolved( &entity, [ @@ -3114,31 +3353,19 @@ impl OpenCADStudio { ], [axis_end.x as f64, axis_end.y as f64, axis_end.z as f64], (angle_deg as f64).to_radians(), - ) - .and_then(|body| Some((solid_model::mesh_from_solid(&body, color)?, body))); - if let Some((mesh, solid)) = result { - let history = crate::scene::model::solid_history::revolve_op( - &entity, - axis_start.to_array(), - axis_end.to_array(), - (angle_deg as f64).to_radians(), - ); + ); + if let Some(solid) = result { + let history = crate::scene::model::solid_history::brep_op(&solid); let pending = self.begin_undo(i, "REVOLVE", 1, true); - let mut s3d = empty_solid3d(); - if let acadrust::EntityType::Solid3D(inner) = &mut s3d { - inner.wires = solid_model::edge_wires(&solid); - } - let new_handle = self.tabs[i].scene.add_entity(s3d); - self.tabs[i] - .scene - .create_solid_history(new_handle, history); - self.tabs[i].scene.register_solid_model(new_handle, solid); - let _ = mesh; - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("REVOLVE: solid created ({:.0}°).", angle_deg).as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + let created = self.add_solid_model(empty_solid3d(), solid, history); + if !created.is_null() { + self.tabs[i].dirty = true; + self.command_line.push_output( + crate::tf!("REVOLVE: solid created ({:.0}°).", angle_deg).as_ref(), + ); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } else { self.command_line @@ -3157,7 +3384,7 @@ impl OpenCADStudio { CmdResult::SweepEntity { profile_handle, path_handle, - color, + color: _, } => { if self.reject_locked_edit(i, profile_handle) || self.reject_locked_edit(i, path_handle) @@ -3166,7 +3393,7 @@ impl OpenCADStudio { return Task::none(); } use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::sweep_model; + use crate::scene::model::{solid_history, sweep_model}; let profile_ent = self.tabs[i] .scene @@ -3174,39 +3401,21 @@ impl OpenCADStudio { .get_entity(profile_handle) .cloned(); let path_ent = self.tabs[i].scene.document.get_entity(path_handle).cloned(); - let history = profile_ent - .as_ref() - .zip(path_ent.as_ref()) - .map(|(profile, path)| { - crate::scene::model::solid_history::sweep_op(profile, path) - }); let result = profile_ent .zip(path_ent) - .and_then(|(profile, path)| sweep_model::swept(&profile, &path, color)); + .and_then(|(profile, path)| sweep_model::swept(&profile, &path)); - if let Some(mut set) = result { + if let Some(solid) = result { let pending = self.begin_undo(i, "SWEEP", 1, true); - let mut entity = empty_solid3d(); - if let acadrust::EntityType::Solid3D(solid) = &mut entity { - let center = set.metrics.centroid; - solid.point_of_reference = acadrust::types::Vector3::new( - center[0], center[1], center[2], - ); - } - let new_handle = self.tabs[i].scene.add_entity(entity); - if let Some(history) = history { - self.tabs[i] - .scene - .create_solid_history(new_handle, history); - } - for mesh in &mut set.lods { - mesh.name = format!("{}", new_handle.value()); - } - self.tabs[i].scene.meshes.insert(new_handle, set); - self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("SWEEP: solid created.").as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + let history = solid_history::brep_op(&solid); + let created = self.add_solid_model(empty_solid3d(), solid, history); + if !created.is_null() { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("SWEEP: solid created.").as_ref()); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } else { self.command_line.push_error(crate::t!("SWEEP: could not sweep the profile along the path.").as_ref()); @@ -3218,7 +3427,7 @@ impl OpenCADStudio { } // ── LOFT ────────────────────────────────────────────────────── - CmdResult::LoftEntities { handles, color } => { + CmdResult::LoftEntities { handles, color: _ } => { if let Some(handle) = handles .iter() .find(|handle| self.tabs[i].scene.is_layer_locked(**handle)) @@ -3229,34 +3438,23 @@ impl OpenCADStudio { return Task::none(); } use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::sweep_model; + use crate::scene::model::{solid_history, sweep_model}; let profiles: Vec = handles .iter() .filter_map(|handle| self.tabs[i].scene.document.get_entity(*handle).cloned()) .collect(); - if let Some(mut set) = sweep_model::lofted(&profiles, color) { - let history = crate::scene::model::solid_history::loft_op(&profiles); + if let Some(solid) = sweep_model::lofted(&profiles) { let pending = self.begin_undo(i, "LOFT", 1, true); - let mut entity = empty_solid3d(); - if let acadrust::EntityType::Solid3D(solid) = &mut entity { - let center = set.metrics.centroid; - solid.point_of_reference = acadrust::types::Vector3::new( - center[0], center[1], center[2], - ); - } - let new_handle = self.tabs[i].scene.add_entity(entity); - self.tabs[i] - .scene - .create_solid_history(new_handle, history); - for mesh in &mut set.lods { - mesh.name = format!("{}", new_handle.value()); - } - self.tabs[i].scene.meshes.insert(new_handle, set); - self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("LOFT: solid created.").as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + let history = solid_history::brep_op(&solid); + let created = self.add_solid_model(empty_solid3d(), solid, history); + if !created.is_null() { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("LOFT: solid created.").as_ref()); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } else { self.command_line.push_error(crate::t!("LOFT: select at least two closed profiles.").as_ref()); @@ -3267,6 +3465,29 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); } + CmdResult::SolidEdgeBlend { + handle, + pick, + value, + fillet, + } => { + let task = self.solid_edge_blend(handle, pick, value, fillet); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + return task; + } + + CmdResult::SolidSubtract { bases, cutters } => { + let task = self.solid_subtract(&bases, &cutters); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + return task; + } + CmdResult::HatcheditApply { handle, name, @@ -3495,10 +3716,24 @@ 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, + initial, + height, + } => { + 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(); + self.command_mtext_input = true; + self.pending_command_editor_text = None; + self.open_mtext_editor(pos, None, &initial, height, None); } CmdResult::OpenTextEditor { pos, @@ -3514,6 +3749,22 @@ impl OpenCADStudio { &initial, height, super::text_inline::TextEntityField::Text, + None, + ); + } + CmdResult::SuspendForTextInput { pos, entity } => { + self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take(); + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + let height = entity.height; + self.open_text_inline( + pos, + None, + "", + height, + super::text_inline::TextEntityField::Text, + Some(entity), ); } CmdResult::EditTextEntity { handle } => { diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 6cf2394a..35e3cc20 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -30,6 +30,7 @@ impl OpenCADStudio { .document .header .paper_space_insertion_base = pt; + self.tabs[i].scene.persist_current_layout_state(); } else { self.tabs[i] .scene @@ -330,8 +331,16 @@ impl OpenCADStudio { .push_error(crate::t!("No user-defined blocks found in this drawing.").as_ref()); } else { use crate::modules::insert::insert_block::InsertBlockCommand; - let cmd = InsertBlockCommand::new(blocks); + let ranked = self.ranked_block_names(&blocks); + let snapshot = self.block_usage_snapshot(); + let cmd = InsertBlockCommand::new_with_usage( + ranked, + snapshot, + self.cliprompt_lines.clamp(0, 50) as u8, + ); self.command_line.push_info(&cmd.prompt()); + let opts = cmd.options(); + self.command_line.set_step_options(opts.clone()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } } @@ -343,8 +352,16 @@ impl OpenCADStudio { .push_error(crate::t!("No user-defined blocks found in this drawing.").as_ref()); } else { use crate::modules::insert::minsert::MinsertCommand; - let cmd = MinsertCommand::new(blocks); + let ranked = self.ranked_block_names(&blocks); + let snapshot = self.block_usage_snapshot(); + let cmd = MinsertCommand::new_with_usage( + ranked, + snapshot, + self.cliprompt_lines.clamp(0, 50) as u8, + ); self.command_line.push_info(&cmd.prompt()); + let opts = cmd.options(); + self.command_line.set_step_options(opts.clone()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } } diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 1f86f876..22ee0d96 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -1,5 +1,22 @@ use super::*; +fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option { + let scene = &app.tabs.get(tab)?.scene; + let selected = scene.selected_handles_in_order(); + let [handle] = selected.as_slice() else { + return None; + }; + + if matches!( + scene.document.get_entity(*handle), + Some(acadrust::EntityType::Solid3D(_)) + ) { + Some(*handle) + } else { + None + } +} + impl OpenCADStudio { pub(super) fn dispatch_dim(&mut self, cmd: &str, i: usize) -> Option> { match cmd { @@ -31,6 +48,17 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "DIMJOGGED" | "DIMJOG" => { + use crate::modules::annotate::jogged_radius_dim::JoggedRadiusDimensionCommand; + let defaults = crate::scene::creation_style::current_dimension_defaults( + &self.tabs[i].scene.document, + ); + let multiplier = self.tabs[i].scene.creation_annotation_multiplier(); + let new_cmd = JoggedRadiusDimensionCommand::new(defaults, multiplier); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + "DIMANGULAR" => { use crate::modules::annotate::angular_dim::AngularDimensionCommand; let new_cmd = AngularDimensionCommand::new(); @@ -38,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(); @@ -577,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" => { @@ -950,6 +982,20 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "SOLIDFILLET" => { + use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; + let command = SolidEdgeCommand::new(EdgeOperation::Fillet, selected_solid(self, i)); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + + "FILLET" if selected_solid(self, i).is_some() => { + use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; + let command = SolidEdgeCommand::new(EdgeOperation::Fillet, selected_solid(self, i)); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "FILLET" => { use crate::modules::draw::modify::fillet::FilletCommand; let entities: Vec<_> = self.tabs[i] @@ -1083,6 +1129,20 @@ impl OpenCADStudio { } } + "SOLIDCHAMFER" => { + use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; + let command = SolidEdgeCommand::new(EdgeOperation::Chamfer, selected_solid(self, i)); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + + "CHAMFER" if selected_solid(self, i).is_some() => { + use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; + let command = SolidEdgeCommand::new(EdgeOperation::Chamfer, selected_solid(self, i)); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "CHAMFER" => { use crate::modules::draw::modify::fillet::ChamferCommand; let entities: Vec<_> = self.tabs[i] @@ -1244,49 +1304,6 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - // DIMJOGGED — create a jogged radius dimension on the selected - // arc/circle: a standard radius dim carrying an OCS_JOGGED XData marker - // that the geometry generator renders with a foreshortened zig-zag. - "DIMJOGGED" | "DIMJOG" => { - use acadrust::entities::{Dimension, DimensionRadius}; - use acadrust::types::Vector3; - use acadrust::xdata::{ExtendedDataRecord, XDataValue}; - let found = - self.tabs[i] - .scene - .selected_entities() - .iter() - .find_map(|(_, e)| match e { - acadrust::EntityType::Arc(a) => Some((a.center, a.radius)), - acadrust::EntityType::Circle(c) => Some((c.center, c.radius)), - _ => None, - }); - let Some((center, radius)) = found else { - self.command_line - .push_error(crate::t!("DIMJOGGED: select an arc or circle first.").as_ref()); - return None; - }; - if radius <= 0.0 { - self.command_line.push_error(crate::t!("DIMJOGGED: invalid radius.").as_ref()); - return None; - } - let k = std::f64::consts::FRAC_1_SQRT_2; - let arc_point = - Vector3::new(center.x + radius * k, center.y + radius * k, center.z); - let mut dim = DimensionRadius::new(center, arc_point); - dim.base.common.layer = self.tabs[i].active_layer.clone(); - let mut rec = ExtendedDataRecord::new("OCS_JOGGED"); - rec.add_value(XDataValue::String("1".to_string())); - dim.base.common.extended_data.add_record(rec); - self.push_undo_snapshot(i, "DIMJOGGED"); - self.tabs[i] - .scene - .add_entity(acadrust::EntityType::Dimension(Dimension::Radius(dim))); - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::t!("DIMJOGGED: created a jogged radius dimension.").as_ref()); - } - // ARCTEXT — lay the text out as one Text entity per character // along the selected arc, each rotated to follow the tangent. "ARCTEXT" => { diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index ed3760e7..4dca1006 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -12,60 +12,327 @@ impl OpenCADStudio { .push_output(crate::t!("PAN: drag with the left mouse button. Press Esc to exit.").as_ref()); } - // ── TABLE cell editing ───────────────────────────────────────────── - // TABLE CELL — set text for a cell in the selected Table + // ── TABLE structure and cell editing ─────────────────────────────── cmd if cmd.starts_with("TABLE ") => { let rest = cmd.trim_start_matches("TABLE").trim(); - let sub_up = rest.split_whitespace().next().unwrap_or("").to_uppercase(); - if sub_up == "CELL" { - let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); - // parts: ["CELL", "", "", ""] - let row_res = parts.get(1).and_then(|s| s.parse::().ok()); - let col_res = parts.get(2).and_then(|s| s.parse::().ok()); - let text = parts.get(3).copied().unwrap_or(""); - match (row_res, col_res) { - (Some(row), Some(col)) => { - let selected_handles: Vec = self.tabs[i] - .scene - .selected_entities() - .iter() - .map(|(h, _)| *h) - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - let mut found = false; - for sh in &selected_handles { - if let Some(acadrust::EntityType::Table(tbl)) = self.tabs[i] - .scene - .document - .entities_mut() - .find(|e| e.common().handle == *sh) - { - if tbl.set_cell_text(row, col, text) { - found = true; - } - } - } - if found { - self.push_undo_snapshot(i, "TABLE CELL"); - self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "TABLE CELL: set [{row},{col}] = \"{text}\"." - ).as_ref()); - } else { - self.command_line.push_error( - crate::t!("TABLE CELL: select a Table entity first, or row/col out of range.").as_ref() - ); - } - } - _ => { - self.command_line - .push_info(crate::t!("Usage: TABLE CELL ").as_ref()); + #[derive(Clone)] + enum TableAction { + Cell(usize, usize, String), + InsertRow(usize), + DeleteRow(usize), + InsertColumn(usize), + DeleteColumn(usize), + Merge(usize, usize, usize, usize), + Unmerge(usize, usize), + RowHeight(usize, f64), + ColumnWidth(usize, f64), + Lock(usize, usize, bool), + Block(usize, usize, String), + Formula(usize, usize, String), + Field(usize, usize, acadrust::Handle), + } + let words: Vec<&str> = rest.split_whitespace().collect(); + let integer = |index: usize| { + words.get(index).and_then(|value| value.parse::().ok()) + }; + let real = |index: usize| { + words.get(index).and_then(|value| value.parse::().ok()) + }; + let action = match words.first().map(|word| word.to_ascii_uppercase()).as_deref() { + Some("CELL") => { + let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); + match (integer(1), integer(2)) { + (Some(row), Some(column)) => Some(TableAction::Cell( + row, + column, + parts.get(3).copied().unwrap_or("").to_string(), + )), + _ => None, } } - } else { + Some("INSERTROW") => integer(1).map(TableAction::InsertRow), + Some("DELETEROW") => integer(1).map(TableAction::DeleteRow), + Some("INSERTCOLUMN") | Some("INSERTCOL") => { + integer(1).map(TableAction::InsertColumn) + } + Some("DELETECOLUMN") | Some("DELETECOL") => { + integer(1).map(TableAction::DeleteColumn) + } + Some("MERGE") => match (integer(1), integer(2), integer(3), integer(4)) { + (Some(r1), Some(c1), Some(r2), Some(c2)) => { + Some(TableAction::Merge(r1, c1, r2, c2)) + } + _ => None, + }, + Some("UNMERGE") => match (integer(1), integer(2)) { + (Some(row), Some(column)) => Some(TableAction::Unmerge(row, column)), + _ => None, + }, + Some("ROWHEIGHT") => match (integer(1), real(2)) { + (Some(row), Some(height)) if height > 0.0 => { + Some(TableAction::RowHeight(row, height)) + } + _ => None, + }, + Some("COLUMNWIDTH") | Some("COLWIDTH") => match (integer(1), real(2)) { + (Some(column), Some(width)) if width > 0.0 => { + Some(TableAction::ColumnWidth(column, width)) + } + _ => None, + }, + Some("LOCK") => match (integer(1), integer(2), words.get(3)) { + (Some(row), Some(column), Some(value)) => Some(TableAction::Lock( + row, + column, + !value.eq_ignore_ascii_case("OFF"), + )), + _ => None, + }, + Some("BLOCK") => { + let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); + match (integer(1), integer(2), parts.get(3)) { + (Some(row), Some(column), Some(name)) if !name.trim().is_empty() => { + Some(TableAction::Block(row, column, name.trim().to_string())) + } + _ => None, + } + } + Some("FORMULA") => { + let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); + match (integer(1), integer(2), parts.get(3)) { + (Some(row), Some(column), Some(expression)) + if !expression.trim().is_empty() => + { + Some(TableAction::Formula( + row, + column, + expression.trim().to_string(), + )) + } + _ => None, + } + } + Some("FIELD") => match (integer(1), integer(2), words.get(3)) { + (Some(row), Some(column), Some(value)) => { + u64::from_str_radix(value.trim_start_matches("0x"), 16) + .ok() + .map(|handle| { + TableAction::Field( + row, + column, + acadrust::Handle::new(handle), + ) + }) + } + _ => None, + }, + _ => None, + }; + let Some(action) = action else { self.command_line.push_info( - "Usage: TABLE (creates new table) or TABLE CELL ", + crate::t!("Usage: TABLE CELL | INSERTROW | DELETEROW | INSERTCOL | DELETECOL | MERGE | UNMERGE | ROWHEIGHT | COLWIDTH | LOCK | BLOCK | FORMULA | FIELD").as_ref(), ); + return Some(Task::none()); + }; + let selected_handles: Vec = self.tabs[i] + .scene + .selected_entities() + .iter() + .map(|(handle, _)| *handle) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + if selected_handles.is_empty() { + self.command_line + .push_error(crate::t!("TABLE: select a table first.").as_ref()); + return Some(Task::none()); + } + let block_handle = if let TableAction::Block(_, _, name) = &action { + self.tabs[i] + .scene + .document + .block_records + .iter() + .find(|record| record.name.eq_ignore_ascii_case(name)) + .map(|record| record.handle) + } else { + None + }; + if matches!(action, TableAction::Block(_, _, _)) && block_handle.is_none() { + self.command_line.push_error( + crate::t!("TABLE: block definition not found.").as_ref(), + ); + return Some(Task::none()); + } + if let TableAction::Field(_, _, field_handle) = &action { + if !self.tabs[i].scene.document.fields.contains_key(field_handle) { + self.command_line.push_error( + crate::t!("TABLE: field handle not found.").as_ref(), + ); + return Some(Task::none()); + } + } + self.push_undo_snapshot(i, "TABLE EDIT"); + let mut changed = false; + for handle in &selected_handles { + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(*handle) + else { + continue; + }; + match &action { + TableAction::Cell(row, column, text) => { + if let Some(cell) = table.cell_mut(*row, *column) { + use acadrust::entities::table::CellStateFlags; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + cell.set_text(text); + changed = true; + } + } + } + TableAction::InsertRow(row) if *row <= table.row_count() => { + let height = table + .rows + .get((*row).min(table.row_count().saturating_sub(1))) + .map(|row| row.height) + .unwrap_or(0.5); + table.insert_row(*row); + table.set_row_height(*row, height); + changed = true; + } + TableAction::DeleteRow(row) + if table.row_count() > 1 && *row < table.row_count() => + { + table.remove_row(*row); + changed = true; + } + TableAction::InsertColumn(column) if *column <= table.column_count() => { + let width = table + .columns + .get((*column).min(table.column_count().saturating_sub(1))) + .map(|column| column.width) + .unwrap_or(2.0); + table.insert_column(*column, width); + changed = true; + } + TableAction::DeleteColumn(column) + if table.column_count() > 1 && *column < table.column_count() => + { + table.remove_column(*column); + changed = true; + } + TableAction::Merge(r1, c1, r2, c2) + if *r1 < table.row_count() + && *r2 < table.row_count() + && *c1 < table.column_count() + && *c2 < table.column_count() => + { + table.merge_cells(acadrust::entities::table::CellRange::new( + (*r1).min(*r2), + (*c1).min(*c2), + (*r1).max(*r2), + (*c1).max(*c2), + )); + changed = true; + } + TableAction::Unmerge(row, column) => { + table.unmerge_cell(*row, *column); + changed = true; + } + TableAction::RowHeight(row, height) if *row < table.row_count() => { + table.set_row_height(*row, *height); + changed = true; + } + TableAction::ColumnWidth(column, width) + if *column < table.column_count() => + { + table.set_column_width(*column, *width); + changed = true; + } + TableAction::Lock(row, column, locked) => { + if let Some(cell) = table.cell_mut(*row, *column) { + use acadrust::entities::table::CellStateFlags; + cell.state.set(CellStateFlags::CONTENT_LOCKED, *locked); + cell.state.set(CellStateFlags::FORMAT_LOCKED, *locked); + changed = true; + } + } + TableAction::Block(row, column, _) => { + if let (Some(handle), Some(cell)) = + (block_handle, table.cell_mut(*row, *column)) + { + use acadrust::entities::table::{ + CellContent, CellStateFlags, CellType, + }; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + cell.contents.clear(); + cell.contents.push(CellContent::block(handle)); + cell.cell_type = CellType::Block; + changed = true; + } + } + } + TableAction::Formula(row, column, expression) => { + if let Some(cell) = table.cell_mut(*row, *column) { + use acadrust::entities::table::CellStateFlags; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + let formula = if expression.starts_with('=') { + expression.clone() + } else { + format!("={expression}") + }; + cell.set_text(&formula); + changed = true; + } + } + } + TableAction::Field(row, column, field_handle) => { + let mut attached = false; + if let Some(cell) = table.cell_mut(*row, *column) { + use acadrust::entities::table::{ + CellContent, CellStateFlags, CellType, + }; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + let mut content = CellContent::text(""); + content.field_handle = Some(*field_handle); + cell.contents.clear(); + cell.contents.push(content); + cell.cell_type = CellType::Text; + attached = true; + } + } + if attached { + if !table.field_handles.contains(field_handle) { + table.field_handles.push(*field_handle); + } + changed = true; + } + } + _ => {} + } + } + if changed { + self.invalidate_property_targets(i, &selected_handles); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line + .push_output(crate::t!("TABLE: edit applied.").as_ref()); + } else { + self.command_line + .push_error(crate::t!( + "TABLE: row/column is out of range or the cell is locked." + ).as_ref()); } } @@ -466,31 +733,55 @@ impl OpenCADStudio { } } - // BOX / SPHERE / CYLINDER / CONE / WEDGE / TORUS are handled by the - // Model-tab primitive command above (with the kernel boolean caching). - // ── EXTRUDE ──────────────────────────────────────────────────── - // PRESSPULL on a closed boundary creates a solid by extruding it to a - // height — the same operation as EXTRUDE. THICKEN turns a closed planar - // profile into a solid of the given thickness, which is also an extrude. - "EXTRUDE" | "PRESSPULL" | "THICKEN" => { + "EXTRUDE" | "THICKEN" => { use crate::modules::insert::solid3d_cmds::ExtrudeCommand; // If a single entity is already selected, skip the pick step. let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); if selected.len() == 1 { let handle = selected[0].0; - let mut cmd = ExtrudeCommand::new(color); - cmd.on_entity_pick(handle, glam::DVec3::ZERO); + let mut cmd = ExtrudeCommand::new_named(cmd, color); + if let Some(curve) = crate::entities::curve::entity_curve(selected[0].1) { + cmd.set_entity_pick_direction( + curve.plane.normal().map(glam::DVec3::from_array), + ); + cmd.on_entity_pick( + handle, + glam::DVec3::from_array(curve.plane.origin), + ); + } self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { - let cmd = ExtrudeCommand::new(color); + let cmd = ExtrudeCommand::new_named(cmd, color); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } } + "PRESSPULL" => { + use crate::modules::insert::solid3d_cmds::PresspullCommand; + let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); + let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); + let mut command = PresspullCommand::new(color); + if selected.len() == 1 + && !matches!(selected[0].1, acadrust::EntityType::Solid3D(_)) + { + if let Some(curve) = crate::entities::curve::entity_curve(selected[0].1) { + command.set_entity_pick_direction( + curve.plane.normal().map(glam::DVec3::from_array), + ); + command.on_entity_pick( + selected[0].0, + glam::DVec3::from_array(curve.plane.origin), + ); + } + } + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + // ── REVOLVE ──────────────────────────────────────────────────── "REVOLVE" => { use crate::modules::insert::solid3d_cmds::RevolveCommand; @@ -1144,8 +1435,7 @@ impl OpenCADStudio { return Some(Task::done(Message::AnnoObjectScaleOpen)); } - // DATALINK — import a CSV file into a table placed at the - // origin (one-time import; a live re-reading link is future work). + // DATALINK — create a persistent linked table. "DATALINK" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new("DATALINK", "DATALINK path to the .csv file:"); @@ -1156,17 +1446,13 @@ impl OpenCADStudio { let path = cmd.trim_start_matches("DATALINK").trim(); if path.is_empty() { self.command_line.push_info( - "Usage: DATALINK — imports the CSV into a table at the origin.", + "Usage: DATALINK ", ); return Some(Task::none()); } match std::fs::read_to_string(path) { Ok(text) => { - let rows_data: Vec> = text - .lines() - .filter(|l| !l.trim().is_empty()) - .map(|line| line.split(',').map(|s| s.trim().to_string()).collect()) - .collect(); + let rows_data = parse_csv_table(&text); let nrows = rows_data.len(); let ncols = rows_data.iter().map(|r| r.len()).max().unwrap_or(0); if nrows == 0 || ncols == 0 { @@ -1186,14 +1472,27 @@ impl OpenCADStudio { table.set_cell_text(r, c, cell); } } - self.push_undo_snapshot(i, "DATALINK"); - self.tabs[i] - .scene - .add_entity_clone(acadrust::EntityType::Table(table)); - self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "DATALINK: imported {nrows}×{ncols} cells into a table at the origin." - ).as_ref()); + let doc = &self.tabs[i].scene.document; + let current_style = doc.header.current_table_style_name.clone(); + table.table_style_handle = doc.objects.iter().find_map(|(handle, object)| { + match object { + acadrust::objects::ObjectType::TableStyle(style) + if style.name.eq_ignore_ascii_case(¤t_style) => + { + Some(*handle) + } + _ => None, + } + }); + let link_path = std::fs::canonicalize(path) + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|_| path.to_string()); + let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::new( + table, &link_path, + ); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); } Err(e) => { self.command_line @@ -1202,6 +1501,151 @@ impl OpenCADStudio { } } + cmd if cmd == "DATALINKUPDATE" || cmd.starts_with("DATALINKUPDATE ") => { + let write_back = cmd + .trim_start_matches("DATALINKUPDATE") + .trim() + .eq_ignore_ascii_case("WRITE"); + let selected: Vec = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .filter_map(|(handle, entity)| { + matches!(entity, acadrust::EntityType::Table(_)).then_some(handle) + }) + .collect(); + let table_handles = if selected.is_empty() { + self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + matches!(entity, acadrust::EntityType::Table(_)) + .then_some(entity.common().handle) + }) + .collect::>() + } else { + selected + }; + let mut jobs = Vec::new(); + for handle in &table_handles { + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity(*handle) + else { + continue; + }; + let link_handle = table + .rows + .iter() + .flat_map(|row| row.cells.iter()) + .find_map(|cell| cell.data_link_handle); + let Some(link_handle) = link_handle else { + continue; + }; + let path = match self.tabs[i].scene.document.objects.get(&link_handle) { + Some(acadrust::objects::ObjectType::ClassObject(object)) => { + match &object.data { + acadrust::objects::ClassObjectData::DataLink(link) => { + Some(link.connection_string.clone()) + } + _ => None, + } + } + _ => None, + }; + if let Some(path) = path { + jobs.push((*handle, link_handle, path)); + } + } + if jobs.is_empty() { + self.command_line + .push_error("DATALINKUPDATE: no linked tables found."); + return Some(Task::none()); + } + if write_back { + let mut written = 0usize; + for (table_handle, _, path) in &jobs { + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity(*table_handle) + else { + continue; + }; + let csv = table_to_csv(table); + if std::fs::write(path, csv).is_ok() { + written += 1; + } + } + self.command_line.push_output( + crate::tf!("DATALINKUPDATE: wrote {} linked source(s).", written).as_ref(), + ); + return Some(Task::none()); + } + let updates: Vec<_> = jobs + .into_iter() + .filter_map(|(table_handle, link_handle, path)| { + std::fs::read_to_string(&path) + .ok() + .map(|text| (table_handle, link_handle, parse_csv_table(&text))) + }) + .filter(|(_, _, rows)| !rows.is_empty()) + .collect(); + if updates.is_empty() { + self.command_line + .push_error("DATALINKUPDATE: linked sources could not be read."); + return Some(Task::none()); + } + self.push_undo_snapshot(i, "DATALINKUPDATE"); + use acadrust::entities::table::CellStateFlags; + let mut changed_handles = Vec::new(); + for (table_handle, link_handle, rows) in updates { + let columns = rows.iter().map(Vec::len).max().unwrap_or(0); + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(table_handle) + else { + continue; + }; + while table.row_count() < rows.len() { + table.add_row(); + } + while table.row_count() > rows.len() { + table.remove_row(table.row_count() - 1); + } + while table.column_count() < columns { + let width = table.columns.last().map(|column| column.width).unwrap_or(2.0); + table.add_column(width); + } + while table.column_count() > columns { + table.remove_column(table.column_count() - 1); + } + for row in 0..rows.len() { + for column in 0..columns { + if let Some(cell) = table.cell_mut(row, column) { + cell.set_text( + rows[row].get(column).map(String::as_str).unwrap_or(""), + ); + cell.has_linked_data = true; + cell.data_link_handle = Some(link_handle); + cell.data_link_rows = rows.len() as i32; + cell.data_link_columns = columns as i32; + cell.state.insert( + CellStateFlags::LINKED + | CellStateFlags::CONTENT_LOCKED + | CellStateFlags::FORMAT_LOCKED, + ); + } + } + } + changed_handles.push(table_handle); + } + self.invalidate_property_targets(i, &changed_handles); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + crate::tf!("DATALINKUPDATE: updated {} linked table(s).", changed_handles.len()) + .as_ref(), + ); + } + // LANDXMLIMPORT — import survey points (LandXML // elements) as Point objects. Reads the coordinate text content // (northing easting elevation) → Point at (easting, northing, elev). @@ -1296,6 +1740,68 @@ fn parse_landxml_cgpoints(xml: &str) -> Vec<[f64; 3]> { out } +fn parse_csv_table(text: &str) -> Vec> { + let mut rows = Vec::new(); + let mut row = Vec::new(); + let mut field = String::new(); + let mut quoted = false; + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '"' if quoted && chars.peek() == Some(&'"') => { + field.push('"'); + chars.next(); + } + '"' => quoted = !quoted, + ',' if !quoted => row.push(std::mem::take(&mut field)), + '\n' if !quoted => { + if field.ends_with('\r') { + field.pop(); + } + row.push(std::mem::take(&mut field)); + if row.iter().any(|value| !value.is_empty()) { + rows.push(std::mem::take(&mut row)); + } else { + row.clear(); + } + } + _ => field.push(ch), + } + } + if field.ends_with('\r') { + field.pop(); + } + if !field.is_empty() || !row.is_empty() { + row.push(field); + if row.iter().any(|value| !value.is_empty()) { + rows.push(row); + } + } + rows +} + +fn table_to_csv(table: &acadrust::entities::Table) -> String { + fn escape(value: &str) -> String { + if value.contains([',', '"', '\r', '\n']) { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_string() + } + } + table + .rows + .iter() + .map(|row| { + row.cells + .iter() + .map(|cell| escape(cell.text_value())) + .collect::>() + .join(",") + }) + .collect::>() + .join("\r\n") +} + #[cfg(test)] mod tests { use crate::app::OpenCADStudio; diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 085cce33..d9edbc86 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -925,21 +925,66 @@ impl OpenCADStudio { } // ── Model commands (3D primitives) ───────────────────────────── - "BOX" | "WEDGE" | "CYLINDER" | "CONE" | "SPHERE" | "TORUS" => { + "BOX" | "WEDGE" | "CYLINDER" | "CONE" | "SPHERE" | "PYRAMID" | "PYR" + | "TORUS" => { use crate::modules::model::primitive_cmd::PrimitiveCommand; let new_cmd = PrimitiveCommand::new(cmd); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - // ── Design commands (solid booleans) ─────────────────────────── - "UNION" | "SUBTRACT" | "INTERSECT" => { + // ── Solid booleans ───────────────────────────────────────────── + "UNION" | "INTERSECT" => { use crate::modules::model::boolean_cmd::BoolOp; if let Some(op) = BoolOp::from_id(cmd) { - return Some(self.solid_boolean(op)); + let solid_count = { + let scene = &self.tabs[i].scene; + scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !scene.is_layer_locked(*handle)) + .filter(|handle| { + matches!( + scene.document.get_entity(*handle), + Some(acadrust::EntityType::Solid3D(_)) + ) + }) + .take(2) + .count() + }; + if solid_count < 2 { + use crate::modules::draw::select::SelectObjectsCommand; + let selection = SelectObjectsCommand::new(cmd); + self.command_line.push_info(&selection.prompt()); + self.tabs[i].active_cmd = Some(Box::new(selection)); + } else { + return Some(self.solid_boolean(op)); + } } } + "SUBTRACT" => { + use crate::modules::model::boolean_cmd::SubtractCommand; + let bases = { + let scene = &self.tabs[i].scene; + scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !scene.is_layer_locked(*handle)) + .filter(|handle| { + matches!( + scene.document.get_entity(*handle), + Some(acadrust::EntityType::Solid3D(_)) + ) + }) + .collect() + }; + self.tabs[i].scene.deselect_all(); + let subtract = SubtractCommand::new(bases); + self.command_line.push_info(&subtract.prompt()); + self.tabs[i].active_cmd = Some(Box::new(subtract)); + } + // INTERFERE — non-destructive intersect: solid from the overlap. "INTERFERE" => { return Some(self.solid_interfere()); @@ -1033,17 +1078,7 @@ impl OpenCADStudio { } } - // PYRAMID [sides] — create an n-sided pyramid mesh. - "PYRAMID" | "PYR" => { - use crate::command::TwoValuePromptCommand; - let c = TwoValuePromptCommand::new( - "PYRAMID", - "PYRAMID base radius:", - "PYRAMID height (add sides by typing a 3rd number):", - ); - self.command_line.push_info(&c.prompt()); - self.tabs[i].active_cmd = Some(Box::new(c)); - } + // PYRAMID [sides] keeps the direct numeric form. cmd if cmd.starts_with("PYRAMID ") || cmd.starts_with("PYR ") => { let nums: Vec = cmd .split_whitespace() @@ -1233,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)); } @@ -1272,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)); } diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index b701f91d..a49778ca 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -943,7 +943,12 @@ impl OpenCADStudio { "ALIGN" => { use crate::modules::draw::modify::align::AlignCommand; - let cmd = AlignCommand::new(); + + let selected: Vec = + self.tabs[i].scene.selected.iter().copied().collect(); + + let cmd = AlignCommand::with_selection(selected); + self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } diff --git a/src/app/commands/layers.rs b/src/app/commands/layers.rs index a0220654..9dfc1e77 100644 --- a/src/app/commands/layers.rs +++ b/src/app/commands/layers.rs @@ -827,30 +827,8 @@ impl OpenCADStudio { } } - // A viewport frames the model, so its framing is measured in model - // units and has to follow them. Left alone, every layout would suddenly - // look at a region a thousand times too large. The paper side of the - // ratio is untouched — the sheet is still the same sheet — so the ratio - // itself moves the other way. - // - // `transform_viewport` covers the rectangle and the target but not the - // framing, so the framing is done here for every viewport, and the - // target only for the ones the scale above did not already reach. - // Not every viewport frames the model, though. Each layout's sheet - // viewport frames the sheet — its view is the paper, measured in paper - // units — so scaling it by the model's factor would leave the drawing - // frame the same size as before while the view of it changed by a - // thousand, which is the boundary appearing to break. - let sheets: std::collections::HashSet = self.tabs[i] - .scene - .document - .objects - .values() - .filter_map(|object| match object { - acadrust::objects::ObjectType::Layout(layout) => Some(layout.viewport), - _ => None, - }) - .collect(); + // Model framing follows model units; sheet framing stays in paper units. + let sheets = self.tabs[i].scene.sheet_viewport_handles(); let scaled: std::collections::HashSet = handles.iter().copied().collect(); let mut reframed = 0usize; for entity in self.tabs[i].scene.document.entities_mut() { @@ -858,14 +836,7 @@ impl OpenCADStudio { let acadrust::entities::EntityType::Viewport(vp) = entity else { continue; }; - // Only a paper-space viewport can be a sheet — the scale above - // reached everything in model space, and what it reached frames the - // model by definition. Among the rest, the layout names its sheet - // outright, and a file that arrives without that link still gives - // itself away by sitting at the paper origin, where only the sheet - // sits. - let is_sheet = !scaled.contains(&handle) - && (sheets.contains(&handle) || !crate::scene::Scene::is_content_viewport(vp)); + let is_sheet = !scaled.contains(&handle) && sheets.contains(&handle); if is_sheet { continue; } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index ed249296..44a33084 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -18,7 +18,7 @@ mod view; // `DrawOrderRefCommand` lives in the `view` family file but is referenced by // path (`commands::DrawOrderRefCommand`) from `update.rs`, so re-export it at // the module root to keep that path valid. -pub(crate) use view::DrawOrderRefCommand; +pub(crate) use view::DrawOrderCommand; impl OpenCADStudio { /// First `"{prefix}{n}"` (n ≥ 1) not already used by a block record in the @@ -390,6 +390,7 @@ inventory::submit!(crate::command::CommandRegistration { "OBJECTSCALE", // Import CSV into a table + LandXML survey points. "DATALINK", + "DATALINKUPDATE", "LANDXMLIMPORT", // Keyboard-shortcut (CUI) export / import. "CUIEXPORT", @@ -397,9 +398,11 @@ inventory::submit!(crate::command::CommandRegistration { "CUILOAD", // Save every open drawing. "SAVEALL", - // Draw-order: all text/dims to front or back. + // Draw-order: all text/dims to front or back; hatches to back. "TEXTTOFRONT", "TEXTTOBACK", + "HATCHTOBACK", + "HB", // Criteria-based selection (same as QSELECT) + copy with picked base. "FILTER", "FI", @@ -530,6 +533,7 @@ inventory::submit!(crate::command::CommandRegistration { "SKETCHINC", "SKPOLY", "SKTOLERANCE", + "CLIPROMPTLINES", // Reset selected entities' overrides to follow their layer. "SETBYLAYER", // Remove duplicate objects; set drawing base point; audit integrity; diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 8f746a2a..39d4cd93 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -412,14 +412,7 @@ impl OpenCADStudio { vec![] }; - // Blocks: everything the layout-reachability walk above did - // NOT mark live. That covers unreferenced named blocks, - // leftover anonymous blocks (*U hatch/unnamed, orphaned *D - // dimension, *T table, dynamic-block variants — AutoCAD drops - // these too), AND whole dead subgraphs a detached xref leaves - // behind. Live xrefs stay (an attached xref's blocks are - // reached from the layout that inserts them); the *Model_Space - // / *Paper_Space containers are never removed. + // Remove unreachable blocks but retain live xrefs and space containers. let block_remove: Vec = if do_blocks { self.tabs[i] .scene @@ -494,15 +487,7 @@ impl OpenCADStudio { n_blocks += block_remove.len(); } - // Draw-order cleanup. A SortEntitiesTable lists a block's - // entities in draw order; purging the block drops the entities - // and the block record, but this object lingers, still holding - // the (now dangling) handle of every deleted entity. On a - // detached-xref purge that is hundreds of thousands of stale - // handles — megabytes of dead weight the save re-emits, which is - // why an OCS-purged file stayed far larger than AutoCAD's. - // Drop every SortEntitiesTable whose owning block is gone - // (AutoCAD's "orphaned data"). + // Remove draw-order tables whose owning blocks are gone. let mut n_sortents = 0usize; if do_blocks { let live_blocks: rustc_hash::FxHashSet = self.tabs[i] @@ -879,6 +864,7 @@ impl OpenCADStudio { | "CURSORTYPE" | "SNAPANG" | "TEXTFILL" + | "CLIPROMPTLINES" | "ATTREQ" | "ATTDIA" | "DIMASSOC" @@ -935,6 +921,8 @@ impl OpenCADStudio { | "SKETCHINC" | "SKPOLY" | "SKTOLERANCE" + | "DONUTID" + | "DONUTOD" | "CENTEREXE" | "CENTERLAYER" | "CENTERLTYPE" @@ -965,7 +953,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE | CLAYER CELTYPE TEXTSTYLE (read-only)", + "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE | CLAYER CELTYPE TEXTSTYLE (read-only)", ); } else { let frame_kind = crate::scene::frame::kind_for_name(&name); @@ -1081,6 +1069,49 @@ impl OpenCADStudio { } return Some(self.finish_dispatch(cmd)); } + if matches!(name.as_str(), "DONUTID" | "DONUTOD") { + let current = if name == "DONUTID" { + crate::modules::draw::defaults::get_donut_inner_diameter() + } else { + crate::modules::draw::defaults::get_donut_outer_diameter() + }; + if let Some(value) = &value { + let parsed = value + .trim() + .replace(',', ".") + .parse::() + .ok() + .filter(|number| number.is_finite()); + let valid = parsed.filter(|number| { + if name == "DONUTID" { + *number >= 0.0 + } else { + *number > 0.0 + } + }); + if let Some(number) = valid { + if name == "DONUTID" { + crate::modules::draw::defaults::set_donut_inner_diameter(number); + } else { + crate::modules::draw::defaults::set_donut_outer_diameter(number); + } + self.command_line + .push_output(&format!("{name} = {number}")); + } else { + self.command_line.push_error(if name == "DONUTID" { + "SETVAR: DONUTID requires a finite value greater than or equal to zero." + } else { + "SETVAR: DONUTOD requires a finite value greater than zero." + }); + } + } else { + self.command_line.push_output(crate::tf!( + "Enter new value for {name} <{current}>:" + ).as_ref()); + self.pending_setvar = Some(name.clone()); + } + return Some(self.finish_dispatch(cmd)); + } // Parse a boolean given as 0/1 or ON/OFF. let parse_bool = |s: &str| match s.to_uppercase().as_str() { "1" | "ON" | "TRUE" => Some(true), @@ -1268,6 +1299,20 @@ impl OpenCADStudio { false, )), }, + "CLIPROMPTLINES" => match &value { + Some(v) => match v.parse::() { + Ok(n) if (0..=50).contains(&n) => { + self.cliprompt_lines = n; + self.command_line.set_cliprompt_lines(n as u8); + Ok((format!("CLIPROMPTLINES = {n}"), true)) + } + _ => Err("SETVAR: integer from 0 to 50 required.".into()), + }, + None => Ok(( + format!("CLIPROMPTLINES = {}", self.cliprompt_lines), + false, + )), + }, "ATTREQ" => match &value { Some(v) => parse_bool(v) .map(|b| { @@ -1844,11 +1889,18 @@ impl OpenCADStudio { | "PICKBOX" | "CURSORTYPE" | "SNAPANG" + | "CLIPROMPTLINES" ) { self.persist_settings_if_changed(); } else { self.tabs[i].dirty = true; } + if name == "FILLMODE" { + self.tabs[i].scene.bump_geometry(); + } + if matches!(name.as_str(), "PSLTSCALE" | "PLIMCHECK") { + self.tabs[i].scene.persist_current_layout_state(); + } self.command_line.push_output(&msg); } else { // Queried with no value (`changed == false`): diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 89ceea80..02dc2222 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -568,63 +568,49 @@ impl OpenCADStudio { } } else { // ── Preset viewport layout ─────────────────────────── - // Determine paper dimensions from PlotSettings (fallback A4 landscape). let layout_name = scene.current_layout.clone(); - let (paper_w, paper_h) = { - use acadrust::objects::ObjectType; - let mut pw = 297.0_f64; - let mut ph = 210.0_f64; - for (_, obj) in &scene.document.objects { - if let ObjectType::PlotSettings(ps) = obj { - if ps.page_name == layout_name && ps.paper_width > 0.0 { - pw = ps.paper_width; - ph = ps.paper_height; - break; - } - } - } - (pw, ph) - }; - let margin = 5.0_f64; // mm margin around the usable area - let uw = paper_w - 2.0 * margin; // usable width - let uh = paper_h - 2.0 * margin; // usable height - // Collect rectangle specs: (cx, cz, w, h) in mm + let ((x0, y0), (x1, y1)) = scene + .printable_area_limits() + .unwrap_or(((0.0, 0.0), (297.0, 210.0))); + let uw = (x1 - x0).max(1.0); + let uh = (y1 - y0).max(1.0); + let gap = 2.0 * scene.paper_space_unit_factor(); let rects: Vec<(f64, f64, f64, f64)> = match sub.as_str() { "2H" => { // Two viewports side by side (horizontal split) - let vw = (uw - 2.0) / 2.0; + let vw = (uw - gap) / 2.0; vec![ - (margin + vw / 2.0, margin + uh / 2.0, vw, uh), - (margin + vw + 2.0 + vw / 2.0, margin + uh / 2.0, vw, uh), + (x0 + vw / 2.0, y0 + uh / 2.0, vw, uh), + (x0 + vw + gap + vw / 2.0, y0 + uh / 2.0, vw, uh), ] } "2V" => { // Two viewports stacked (vertical split) - let vh = (uh - 2.0) / 2.0; + let vh = (uh - gap) / 2.0; vec![ - (margin + uw / 2.0, margin + vh + 2.0 + vh / 2.0, uw, vh), - (margin + uw / 2.0, margin + vh / 2.0, uw, vh), + (x0 + uw / 2.0, y0 + vh + gap + vh / 2.0, uw, vh), + (x0 + uw / 2.0, y0 + vh / 2.0, uw, vh), ] } "4" => { // Four equal viewports (2×2 grid) - let vw = (uw - 2.0) / 2.0; - let vh = (uh - 2.0) / 2.0; + let vw = (uw - gap) / 2.0; + let vh = (uh - gap) / 2.0; vec![ - (margin + vw / 2.0, margin + vh + 2.0 + vh / 2.0, vw, vh), + (x0 + vw / 2.0, y0 + vh + gap + vh / 2.0, vw, vh), ( - margin + vw + 2.0 + vw / 2.0, - margin + vh + 2.0 + vh / 2.0, + x0 + vw + gap + vw / 2.0, + y0 + vh + gap + vh / 2.0, vw, vh, ), - (margin + vw / 2.0, margin + vh / 2.0, vw, vh), - (margin + vw + 2.0 + vw / 2.0, margin + vh / 2.0, vw, vh), + (x0 + vw / 2.0, y0 + vh / 2.0, vw, vh), + (x0 + vw + gap + vw / 2.0, y0 + vh / 2.0, vw, vh), ] } "SINGLE" | "1" => { // Single full-page viewport - vec![(margin + uw / 2.0, margin + uh / 2.0, uw, uh)] + vec![(x0 + uw / 2.0, y0 + uh / 2.0, uw, uh)] } _ => { self.command_line.push_error( @@ -772,17 +758,20 @@ impl OpenCADStudio { // the front (or back) of the draw order via the DRAWORDER machinery. "TEXTTOFRONT" | "TEXTTOBACK" => { let to_front = cmd.ends_with("FRONT"); + let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); let handles: rustc_hash::FxHashSet = self.tabs[i] .scene .document .entities() .filter(|e| { - matches!( - e, - acadrust::EntityType::Text(_) - | acadrust::EntityType::MText(_) - | acadrust::EntityType::Dimension(_) - ) + let c = e.common(); + (c.owner_handle == block_handle || c.owner_handle.is_null()) + && matches!( + e, + acadrust::EntityType::Text(_) + | acadrust::EntityType::MText(_) + | acadrust::EntityType::Dimension(_) + ) }) .map(|e| e.common().handle) .collect(); @@ -802,15 +791,140 @@ impl OpenCADStudio { ); } - "DRAWORDER" => { - use crate::command::SelectThenKeywordCommand; - let has_sel = !self.tabs[i].scene.selected_entities().is_empty(); - let c = SelectThenKeywordCommand::new( - "DRAWORDER", - "DRAWORDER [Front / Back] (Above/Under by typing):", - vec![("Front", "FRONT", None), ("Back", "BACK", None)], - has_sel, + // HATCHTOBACK ÔÇö move every hatch object in the active space to the back of the draw order. + "HATCHTOBACK" => { + use acadrust::objects::{ObjectType, SortEntitiesTable}; + let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); + let doc_ref = &self.tabs[i].scene.document; + + // 1. Single scan over objects to find existing SortEntitiesTable handle & overrides. + let mut existing_table_handle = None; + let mut overrides: Option> = None; + for (h, obj) in &doc_ref.objects { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + existing_table_handle = Some(*h); + if !t.is_empty() { + overrides = Some( + t.entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(), + ); + } + break; + } + } + } + + // 2. Pre-filter locked layers with zero string allocation. + let locked_layers: rustc_hash::FxHashSet<&str> = doc_ref + .layers + .iter() + .filter(|l| l.is_locked()) + .map(|l| l.name.as_str()) + .collect(); + let has_locked_layers = !locked_layers.is_empty(); + + // 3. Single pass over entities in the active space. + let mut hatches_to_move: Vec = Vec::new(); + let mut min_eff = u64::MAX; + + for e in doc_ref.entities() { + let c = e.common(); + if c.owner_handle != block_handle && !c.owner_handle.is_null() { + continue; + } + let hv = c.handle.value(); + if matches!(e, acadrust::EntityType::Hatch(_)) { + if !has_locked_layers || !locked_layers.contains(c.layer.as_str()) { + hatches_to_move.push(c.handle); + } + } else { + let eff = match &overrides { + Some(map) => map.get(&hv).copied().unwrap_or(hv), + None => hv, + }; + min_eff = min_eff.min(eff); + } + } + + if hatches_to_move.is_empty() { + self.command_line + .push_info(crate::tf!("{cmd}: no hatch objects.").as_ref()); + return Some(Task::none()); + } + + if min_eff == u64::MAX { + min_eff = 1; + } + + // Compute all sort-key assignments before any mutable step: + // the exhausted-floor path needs one more read-only pass over + // entities to find colliding siblings to lift. + let assigns = assign_back_group_keys( + doc_ref, + block_handle, + &hatches_to_move, + min_eff, + overrides.as_ref(), + &locked_layers, ); + + // 4. Ultra-fast targeted Delta Undo (snapshots ONLY the SortEntitiesTable, zero full-drawing clone). + let pending_delta = self.begin_undo(i, "DRAWORDER", hatches_to_move.len(), true); + + // 5. Update or insert SortEntitiesTable directly. + let table_before = existing_table_handle + .and_then(|h| self.tabs[i].scene.document.objects.get(&h).cloned()); + let th = existing_table_handle.unwrap_or_else(|| { + let nh = acadrust::Handle::new(self.tabs[i].scene.document.next_handle()); + let mut table = SortEntitiesTable::for_block(block_handle); + table.handle = nh; + self.tabs[i] + .scene + .document + .objects + .insert(nh, ObjectType::SortEntitiesTable(table)); + nh + }); + self.tabs[i].scene.record_undo_object_before(th, table_before); + + if let Some(ObjectType::SortEntitiesTable(table)) = + self.tabs[i].scene.document.objects.get_mut(&th) + { + for (h, sort) in &assigns { + table.add_entry(*h, acadrust::Handle::new(*sort)); + } + } + + if let Some(pending) = pending_delta { + self.commit_undo_delta(i, pending); + } + + // 6. Invalidate ONLY draw-depth cache without dropping whole-drawing tessellations or spatial indexes. + let changes: Vec<(acadrust::Handle, crate::scene::ChangeKind)> = hatches_to_move + .iter() + .map(|h| (*h, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.invalidate_draw_depth(); + + self.tabs[i].dirty = true; + self.command_line.push_info( + crate::tf!("DRAWORDER: moved {} entities to back.", hatches_to_move.len()).as_ref(), + ); + + return Some(Task::none()); + } + + "DRAWORDER" => { + let selected: Vec = self.tabs[i] + .scene + .selected_entities() + .iter() + .map(|(h, _)| *h) + .collect(); + let c = DrawOrderCommand::new(selected); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } @@ -857,6 +971,7 @@ impl OpenCADStudio { // entities land strictly above/below every sibling — // including ones not yet in the table, which sort by // their own handle. (min_eff, max_eff) over siblings. + let mut back_assigns: Option> = None; let fb_baseline: Option<(u64, u64)> = if to_front_opt.is_some() { let selected_set: rustc_hash::FxHashSet = selected.iter().map(|h| h.value()).collect(); @@ -900,6 +1015,24 @@ impl OpenCADStudio { if min_eff == u64::MAX { min_eff = 1; } + if to_front_opt == Some(false) { + // Same exhausted-floor handling as HATCHTOBACK: + // never clamp the group onto tied keys. + let locked_layers: rustc_hash::FxHashSet<&str> = doc_ref + .layers + .iter() + .filter(|l| l.is_locked()) + .map(|l| l.name.as_str()) + .collect(); + back_assigns = Some(assign_back_group_keys( + doc_ref, + block_handle, + &selected, + min_eff, + Some(&overrides), + &locked_layers, + )); + } Some((min_eff, max_eff)) } else { None @@ -917,28 +1050,6 @@ impl OpenCADStudio { None } }); - let get_or_create = - |doc: &mut acadrust::CadDocument, block_handle| -> acadrust::Handle { - if let Some(th) = doc.objects.iter().find_map(|(h, obj)| { - if let ObjectType::SortEntitiesTable(t) = obj { - if t.block_owner_handle == block_handle { - Some(*h) - } else { - None - } - } else { - None - } - }) { - th - } else { - let nh = acadrust::Handle::new(doc.next_handle()); - let mut table = SortEntitiesTable::for_block(block_handle); - table.handle = nh; - doc.objects.insert(nh, ObjectType::SortEntitiesTable(table)); - nh - } - }; let th = table_handle.unwrap_or_else(|| { let nh = acadrust::Handle::new(doc.next_handle()); let mut table = SortEntitiesTable::for_block(block_handle); @@ -946,24 +1057,37 @@ impl OpenCADStudio { doc.objects.insert(nh, ObjectType::SortEntitiesTable(table)); nh }); - let _ = get_or_create; // suppress unused warning if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&th) { if let Some((above, target)) = relative_target { - // move_above/move_below read the target's sort - // handle from the table and no-op when it is - // absent. A reference object that was never - // reordered isn't in the table yet, so seed it - // with its own handle as the implicit sort key. - if !table.contains(target) { - table.add_entry(target, target); - } - for h in &selected { - if above { - table.move_above(*h, target); - } else { - table.move_below(*h, target); + // move_above/move_below recompute target±1 per + // call, so looping them over N selected entities + // ties the whole group onto one key. Read the + // reference key once and hand out per-index + // keys instead, keeping the moved entities a + // distinct, selection-ordered block adjacent to + // the reference. + let target_sort = match table.get_sort_handle(target) { + Some(h) => h.value(), + None => { + // A reference object that was never + // reordered isn't in the table yet; + // seed it with its own handle as the + // implicit sort key. + table.add_entry(target, target); + table + .get_sort_handle(target) + .map_or(target.value(), |h| h.value()) } + }; + for (k, h) in selected.iter().enumerate() { + let offset = 1 + k as u64; + let sort = if above { + target_sort.saturating_add(offset) + } else { + target_sort.saturating_sub(offset).max(1) + }; + table.add_entry(*h, acadrust::Handle::new(sort)); } let rel = if above { "above" } else { "below" }; self.command_line.push_info(crate::tf!( @@ -973,14 +1097,16 @@ impl OpenCADStudio { target.value() ).as_ref()); } else if let Some(to_front) = to_front_opt { - let (min_eff, max_eff) = fb_baseline.unwrap_or((1, 0)); - for (k, h) in selected.iter().enumerate() { - let sort = if to_front { - max_eff.saturating_add(1 + k as u64) - } else { - min_eff.saturating_sub(1 + k as u64).max(1) - }; - table.add_entry(*h, acadrust::Handle::new(sort)); + if to_front { + let (_, max_eff) = fb_baseline.unwrap_or((1, 0)); + for (k, h) in selected.iter().enumerate() { + let sort = max_eff.saturating_add(1 + k as u64); + table.add_entry(*h, acadrust::Handle::new(sort)); + } + } else if let Some(assigns) = &back_assigns { + for (h, sort) in assigns { + table.add_entry(*h, acadrust::Handle::new(*sort)); + } } let dir = if to_front { "front" } else { "back" }; self.command_line.push_info(crate::tf!( @@ -1083,38 +1209,159 @@ impl OpenCADStudio { } } -// ── Draw Order: interactive reference-object pick ────────────────────────── - -/// Moves a captured selection above or below a reference object the user -/// picks in the viewport. On pick it relaunches `DRAWORDER A|U ` -/// with the captured handles reinstalled as the selection, so the existing -/// command path performs the actual reorder. -pub(crate) struct DrawOrderRefCommand { - to_move: Vec, - above: bool, +// ── Draw Order: interactive command ────────────────────────── + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DrawOrderStep { + SelectObjects, + ChooseVerb, + PickReference { above: bool }, } -impl DrawOrderRefCommand { - pub(crate) fn new(to_move: Vec, above: bool) -> Self { - Self { to_move, above } +/// Interactive front-end for the DRAWORDER command. +/// +/// Flow: +/// 1. If nothing is selected, prompt to select objects (Enter confirms). +/// 2. Prompt for verb: `[Above / Under / Front / Back]`. +/// - `Front` / `F`: moves selection to front. +/// - `Back` / `B` / Enter: moves selection to back. +/// - `Above` / `A` / `Under` / `U`: advances to reference object pick. +/// 3. Reference object pick: user can click the reference entity in the viewport +/// or type its hex handle on the command line. +pub(crate) struct DrawOrderCommand { + selected: Vec, + step: DrawOrderStep, +} + +impl DrawOrderCommand { + pub(crate) fn new(selected: Vec) -> Self { + let step = if selected.is_empty() { + DrawOrderStep::SelectObjects + } else { + DrawOrderStep::ChooseVerb + }; + Self { selected, step } + } + + pub(crate) fn for_reference_pick(selected: Vec, above: bool) -> Self { + Self { + selected, + step: DrawOrderStep::PickReference { above }, + } } } -impl CadCommand for DrawOrderRefCommand { +impl CadCommand for DrawOrderCommand { fn name(&self) -> &'static str { "DRAWORDER" } fn prompt(&self) -> String { - if self.above { - crate::t!("DRAWORDER Select reference object (move selection above):").into_owned() - } else { - crate::t!("DRAWORDER Select reference object (move selection under):").into_owned() + match self.step { + DrawOrderStep::SelectObjects => { + crate::t!("DRAWORDER select objects, then press Enter:").into_owned() + } + DrawOrderStep::ChooseVerb => { + crate::t!("DRAWORDER [Above / Under / Front / Back] :").into_owned() + } + DrawOrderStep::PickReference { above: true } => { + crate::t!("DRAWORDER Select reference object (move selection above):").into_owned() + } + DrawOrderStep::PickReference { above: false } => { + crate::t!("DRAWORDER Select reference object (move selection under):").into_owned() + } + } + } + + fn options(&self) -> Vec { + match self.step { + DrawOrderStep::ChooseVerb => vec![ + crate::command::CmdOption::new("Above", "ABOVE"), + crate::command::CmdOption::new("Under", "UNDER"), + crate::command::CmdOption::new("Front", "FRONT"), + crate::command::CmdOption::new("Back", "BACK"), + ], + _ => Vec::new(), + } + } + + fn wants_text_input(&self) -> bool { + !matches!(self.step, DrawOrderStep::SelectObjects) + } + + fn is_selection_gathering(&self) -> bool { + matches!(self.step, DrawOrderStep::SelectObjects) + } + + fn on_selection_complete(&mut self, handles: Vec) -> crate::command::CmdResult { + self.selected = handles; + crate::command::CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> crate::command::CmdResult { + match self.step { + DrawOrderStep::SelectObjects => { + if self.selected.is_empty() { + crate::command::CmdResult::Cancel + } else { + self.step = DrawOrderStep::ChooseVerb; + crate::command::CmdResult::NeedPoint + } + } + DrawOrderStep::ChooseVerb => { + // Bare Enter defaults to Back + let handles = std::mem::take(&mut self.selected); + crate::command::CmdResult::Relaunch("DRAWORDER BACK".into(), handles) + } + DrawOrderStep::PickReference { .. } => crate::command::CmdResult::Cancel, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let t = text.trim(); + if t.is_empty() { + return None; + } + match self.step { + DrawOrderStep::SelectObjects => None, + DrawOrderStep::ChooseVerb => { + let up = t.to_uppercase(); + match up.as_str() { + "F" | "FRONT" => { + let handles = std::mem::take(&mut self.selected); + Some(crate::command::CmdResult::Relaunch("DRAWORDER FRONT".into(), handles)) + } + "B" | "BACK" => { + let handles = std::mem::take(&mut self.selected); + Some(crate::command::CmdResult::Relaunch("DRAWORDER BACK".into(), handles)) + } + "A" | "ABOVE" => { + self.step = DrawOrderStep::PickReference { above: true }; + Some(crate::command::CmdResult::NeedPoint) + } + "U" | "UNDER" | "BELOW" => { + self.step = DrawOrderStep::PickReference { above: false }; + Some(crate::command::CmdResult::NeedPoint) + } + _ => Some(crate::command::CmdResult::NeedPoint), + } + } + DrawOrderStep::PickReference { above } => { + let hex_str = t.trim_start_matches("0x").trim_start_matches("0X"); + if let Ok(val) = u64::from_str_radix(hex_str, 16) { + let opt = if above { "A" } else { "U" }; + let cmd = format!("DRAWORDER {} {:x}", opt, val); + let handles = std::mem::take(&mut self.selected); + Some(crate::command::CmdResult::Relaunch(cmd, handles)) + } else { + Some(crate::command::CmdResult::NeedPoint) + } + } } } fn needs_entity_pick(&self) -> bool { - true + matches!(self.step, DrawOrderStep::PickReference { .. }) } fn on_entity_pick( @@ -1125,16 +1372,900 @@ impl CadCommand for DrawOrderRefCommand { if handle.is_null() { return crate::command::CmdResult::NeedPoint; } - let opt = if self.above { "A" } else { "U" }; - let cmd = format!("DRAWORDER {} {:x}", opt, handle.value()); - crate::command::CmdResult::Relaunch(cmd, std::mem::take(&mut self.to_move)) + if let DrawOrderStep::PickReference { above } = self.step { + let opt = if above { "A" } else { "U" }; + let cmd = format!("DRAWORDER {} {:x}", opt, handle.value()); + let handles = std::mem::take(&mut self.selected); + crate::command::CmdResult::Relaunch(cmd, handles) + } else { + crate::command::CmdResult::NeedPoint + } } fn on_point(&mut self, _pt: glam::DVec3) -> crate::command::CmdResult { crate::command::CmdResult::NeedPoint } +} - fn on_enter(&mut self) -> crate::command::CmdResult { - crate::command::CmdResult::Cancel +/// Sort-key assignments sending `group` to the back of the active space. +/// +/// Normal case: the floor is the lowest effective sort key among non-moved +/// siblings, and there are enough unused slots below it — hand them out in +/// ascending order so the group lands as one block strictly behind everything +/// else with its internal stacking order preserved. +/// +/// Exhausted case (`floor <= group.len()`, e.g. a sibling already pinned at +/// key 1 or 2): decrementing would clamp every member onto one tied key via +/// `.max(1)`. Instead pin the group to keys 1..=n and lift every other +/// in-space sibling whose effective key collides with that range above it, +/// shifted by `n` so lifted siblings keep their relative order. Locked-layer +/// entities are never moved or lifted. +fn assign_back_group_keys( + doc: &acadrust::CadDocument, + block_handle: acadrust::Handle, + group: &[acadrust::Handle], + floor: u64, + overrides: Option<&rustc_hash::FxHashMap>, + locked_layers: &rustc_hash::FxHashSet<&str>, +) -> Vec<(acadrust::Handle, u64)> { + let n = group.len() as u64; + let mut out: Vec<(acadrust::Handle, u64)> = Vec::with_capacity(group.len()); + if n == 0 { + return out; + } + if floor > n { + for (k, h) in group.iter().enumerate() { + out.push((*h, floor - n + k as u64)); + } + return out; + } + for (k, h) in group.iter().enumerate() { + out.push((*h, 1 + k as u64)); + } + let moved: rustc_hash::FxHashSet = group.iter().map(|h| h.value()).collect(); + let has_locked = !locked_layers.is_empty(); + let mut lifts: Vec<(acadrust::Handle, u64)> = Vec::new(); + for e in doc.entities() { + let c = e.common(); + if c.owner_handle != block_handle && !c.owner_handle.is_null() { + continue; + } + let hv = c.handle.value(); + if moved.contains(&hv) { + continue; + } + if has_locked && locked_layers.contains(c.layer.as_str()) { + continue; + } + let eff = overrides.map_or(hv, |m| m.get(&hv).copied().unwrap_or(hv)); + if eff <= n { + lifts.push((c.handle, eff.saturating_add(n))); + } + } + out.extend(lifts); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::OpenCADStudio; + use acadrust::objects::ObjectType; + use acadrust::EntityType; + + // Benchmark, not a correctness test: measures end-to-end latency of + // HATCHTOBACK and interactive DRAWORDER BACK on a large synthetic drawing. + // Ignored by default; A/B across commits with: + // BENCH_LINES=100000 BENCH_HATCHES=350 BENCH_RUNS=5 \ + // cargo test --release --lib bench_draworder_large_drawing -- --ignored --nocapture + #[test] + #[ignore] + fn bench_draworder_large_drawing() { + let lines_n: usize = std::env::var("BENCH_LINES").ok().and_then(|v| v.parse().ok()).unwrap_or(100_000); + let hatches_n: usize = std::env::var("BENCH_HATCHES").ok().and_then(|v| v.parse().ok()).unwrap_or(350); + let runs: usize = std::env::var("BENCH_RUNS").ok().and_then(|v| v.parse().ok()).unwrap_or(5); + let sel_n: usize = std::env::var("BENCH_SEL").ok().and_then(|v| v.parse().ok()).unwrap_or(100); + + fn median(v: &mut [f64]) -> f64 { + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + v[v.len() / 2] + } + + // --- HATCHTOBACK --- + let mut hb_ms: Vec = Vec::new(); + for _ in 0..runs { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let t0 = std::time::Instant::now(); + let lines: Vec = + (0..lines_n).map(|_| EntityType::Line(Default::default())).collect(); + let _ = app.tabs[i].scene.add_entities(lines); + let hatches: Vec = + (0..hatches_n).map(|_| EntityType::Hatch(Default::default())).collect(); + app.tabs[i].scene.add_entities(hatches); + eprintln!( + "[bench] setup ({} lines + {} hatches): {:.1} ms", + lines_n, + hatches_n, + t0.elapsed().as_secs_f64() * 1e3 + ); + let t1 = std::time::Instant::now(); + let _ = app.run_command_line("HATCHTOBACK"); + hb_ms.push(t1.elapsed().as_secs_f64() * 1e3); + } + eprintln!("[bench] HATCHTOBACK median: {:.3} ms over {} runs", median(&mut hb_ms), runs); + + // --- DRAWORDER BACK (interactive path) --- + let mut db_ms: Vec = Vec::new(); + for _ in 0..runs { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let lines: Vec = + (0..lines_n).map(|_| EntityType::Line(Default::default())).collect(); + let _ = app.tabs[i].scene.add_entities(lines); + let hatches: Vec = + (0..hatches_n).map(|_| EntityType::Hatch(Default::default())).collect(); + let handles = app.tabs[i].scene.add_entities(hatches); + app.tabs[i] + .scene + .replace_selection(handles.into_iter().take(sel_n).collect()); + let t1 = std::time::Instant::now(); + let _ = app.run_command_line("DRAWORDER BACK"); + db_ms.push(t1.elapsed().as_secs_f64() * 1e3); + } + eprintln!( + "[bench] DRAWORDER BACK ({} selected) median: {:.3} ms over {} runs", + sel_n, median(&mut db_ms), runs + ); + } + + fn fresh_app() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app + } + + #[test] + fn hatchtoback_no_hatches_warns_and_noop() { + let mut app = fresh_app(); + let _ = app.run_command_line("HATCHTOBACK"); + let i = app.active_tab; + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table_exists = app.tabs[i].scene.document.objects.values().any(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + t.block_owner_handle == block_handle + } else { + false + } + }); + assert!(!table_exists, "No SortEntitiesTable created when no hatches exist"); + } + + #[test] + fn hatchtoback_moves_hatches_to_back() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_line2 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist for the active layout block"); + + let entries: rustc_hash::FxHashMap = table + .entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(); + + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line1_sort = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); + let line2_sort = entries.get(&h_line2.value()).copied().unwrap_or(h_line2.value()); + + assert!( + hatch_sort < line1_sort, + "Hatch sort handle ({hatch_sort}) must be behind line 1 ({line1_sort})" + ); + assert!( + hatch_sort < line2_sort, + "Hatch sort handle ({hatch_sort}) must be behind line 2 ({line2_sort})" + ); + } + + #[test] + fn hb_alias_moves_hatches_to_back() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HB"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist for the active layout block"); + + let entries: rustc_hash::FxHashMap = table + .entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(); + + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + + assert!( + hatch_sort < line_sort, + "Hatch sort handle ({hatch_sort}) must be behind line ({line_sort}) via HB alias" + ); + } + + #[test] + fn hatchtoback_ignores_hatches_in_other_spaces() { + let mut app = fresh_app(); + let i = app.active_tab; + let other_block = acadrust::Handle::new(0x9999); + let h_foreign = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(h_foreign) { + entity.common_mut().owner_handle = other_block; + } + + // When no hatches exist in the current layout, HATCHTOBACK should no-op. + let _ = app.run_command_line("HATCHTOBACK"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table_exists = app.tabs[i].scene.document.objects.values().any(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + t.block_owner_handle == block_handle + } else { + false + } + }); + assert!(!table_exists, "Foreign hatch must not trigger table creation in active layout"); + + // Now add a line and a hatch in the active layout. + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist for current layout"); + + let entries: rustc_hash::FxHashMap = table + .entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(); + + assert!(!entries.contains_key(&h_foreign.value()), "Foreign hatch must not be in active layout table"); + assert!(entries.contains_key(&h_hatch.value()), "Active layout hatch must be in table"); + let hatch_sort = entries[&h_hatch.value()]; + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + assert!(hatch_sort < line_sort); + } + + #[test] + fn hatchtoback_multiple_calls_are_idempotent() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch1 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_hatch2 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + let _ = app.run_command_line("HATCHTOBACK"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist"); + + let entries: rustc_hash::FxHashMap = table + .entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(); + + let h1_sort = entries.get(&h_hatch1.value()).copied().unwrap_or(h_hatch1.value()); + let h2_sort = entries.get(&h_hatch2.value()).copied().unwrap_or(h_hatch2.value()); + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + + assert!(h1_sort < line_sort); + assert!(h2_sort < line_sort); + } + + #[test] + fn hatchtoback_preserves_active_selection() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let _h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // User had the line selected before running HATCHTOBACK. + app.tabs[i].scene.replace_selection(std::iter::once(h_line).collect()); + + let _ = app.run_command_line("HATCHTOBACK"); + + let selected = app.tabs[i].scene.selected_handles_in_order(); + assert_eq!(selected, vec![h_line], "HATCHTOBACK must not overwrite existing user selection"); + } + + #[test] + fn hatchtoback_skips_hatches_on_locked_layers() { + let mut app = fresh_app(); + let i = app.active_tab; + + // Lock layer "LOCKED_LAYER". + app.tabs[i].scene.ensure_layer("LOCKED_LAYER"); + if let Some(layer) = app.tabs[i].scene.document.layers.get_mut("LOCKED_LAYER") { + layer.flags.locked = true; + } + + let mut locked_hatch = acadrust::entities::Hatch::default(); + locked_hatch.common.layer = "LOCKED_LAYER".into(); + let h_locked_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(locked_hatch)); + + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + + // Run HATCHTOBACK when only a locked hatch exists: it should not move the locked hatch. + let _ = app.run_command_line("HATCHTOBACK"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table_exists = app.tabs[i].scene.document.objects.values().any(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + t.block_owner_handle == block_handle + } else { + false + } + }); + assert!(!table_exists, "Locked hatch must not trigger table creation"); + + // Now add an unlocked hatch. + let h_unlocked_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist for unlocked hatch"); + + let entries: rustc_hash::FxHashMap = table + .entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(); + + assert!(!entries.contains_key(&h_locked_hatch.value()), "Locked hatch must not be reordered"); + assert!(entries.contains_key(&h_unlocked_hatch.value()), "Unlocked hatch must be reordered"); + let hatch_sort = entries[&h_unlocked_hatch.value()]; + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + assert!(hatch_sort < line_sort); + } + + // HATCHTOBACK must preserve the moved group's internal stacking order. + // Written while auditing the descending key assignment, which silently + // REVERSES hatch-to-hatch order; this test fails while that holds. + #[test] + fn hatchtoback_preserves_hatch_stacking_order() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch1 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_hatch2 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_hatch3 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + + let entries = effective_sort_map(&app); + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + let s1 = entries.get(&h_hatch1.value()).copied().unwrap_or(h_hatch1.value()); + let s2 = entries.get(&h_hatch2.value()).copied().unwrap_or(h_hatch2.value()); + let s3 = entries.get(&h_hatch3.value()).copied().unwrap_or(h_hatch3.value()); + + assert!(s3 > s2 && s2 > s1, "stacking order must be preserved, got {s1}, {s2}, {s3}"); + assert!(s1 < line_sort && s2 < line_sort && s3 < line_sort, "all hatches must stay behind the line"); + } + + // When the key space below the floor is exhausted (an entity already sits + // at a very low sort key), HATCHTOBACK must not clamp every hatch onto one + // tied key: hatches stay pairwise distinct and no sibling may sink below + // them. Fails while `.max(1)` clamping is in place. + #[test] + fn hatchtoback_exhausted_floor_keeps_hatches_distinct_and_behind() { + let mut app = fresh_app(); + let i = app.active_tab; + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch1 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_hatch2 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_hatch3 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // Pin the line to sort key 2: only ONE slot below the floor exists, + // but three hatches need to fit behind it. + { + use acadrust::objects::{ObjectType, SortEntitiesTable}; + let doc = &mut app.tabs[i].scene.document; + let nh = acadrust::Handle::new(doc.next_handle()); + let mut table = SortEntitiesTable::for_block(block_handle); + table.handle = nh; + table.add_entry(h_line, acadrust::Handle::new(2)); + doc.objects.insert(nh, ObjectType::SortEntitiesTable(table)); + } + + let _ = app.run_command_line("HATCHTOBACK"); + + let entries = effective_sort_map(&app); + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + let s1 = entries.get(&h_hatch1.value()).copied().unwrap_or(h_hatch1.value()); + let s2 = entries.get(&h_hatch2.value()).copied().unwrap_or(h_hatch2.value()); + let s3 = entries.get(&h_hatch3.value()).copied().unwrap_or(h_hatch3.value()); + + assert_ne!(s1, s2, "hatches must not tie on one clamped key"); + assert_ne!(s1, s3, "hatches must not tie on one clamped key"); + assert_ne!(s2, s3, "hatches must not tie on one clamped key"); + assert!( + line_sort > s1.max(s2).max(s3), + "the pinned line ({line_sort}) must still render above every hatch ({s1}, {s2}, {s3})" + ); + } + + #[test] + fn hatchtoback_undo_restores_draw_order() { + let mut app = fresh_app(); + let i = app.active_tab; + let _h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist"); + assert!(table.entries().any(|e| e.entity_handle == h_hatch)); + + // Perform UNDO. + let _ = app.update(crate::app::Message::Undo); + + let table_after_undo = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }); + // Table was created fresh by HATCHTOBACK, so undo should remove it or leave it empty of the hatch. + let has_hatch = table_after_undo.map_or(false, |t| t.entries().any(|e| e.entity_handle == h_hatch)); + assert!(!has_hatch, "Undo must revert the SortEntitiesTable entry"); + } + + fn effective_sort_map( + app: &OpenCADStudio, + ) -> rustc_hash::FxHashMap { + let i = app.active_tab; + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + app.tabs[i] + .scene + .document + .objects + .values() + .find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some( + t.entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(), + ); + } + } + None + }) + .unwrap_or_default() + } + + // Regression guard for DRAWORDER BACK anchoring. Written while auditing + // the draw-order code, where BACK *looked* like it skipped the baseline + // scan and clamped every moved entity onto sort key 1 (a tie). Running + // this test disproved that — `to_front_opt` is Some(false) for BACK, so + // the baseline scan does run. Kept to lock in the strict-order behavior: + // a second BACK must land strictly below earlier assignments, never tie. + #[test] + fn draworder_back_twice_keeps_strict_order() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // Send the hatch to back, then send line 1 behind it: the second BACK + // must anchor strictly below the first assignment, never tie with it. + app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); + let _ = app.run_command_line("DRAWORDER BACK"); + app.tabs[i].scene.replace_selection(std::iter::once(h_line1).collect()); + let _ = app.run_command_line("DRAWORDER BACK"); + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line1_sort = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); + + assert!( + line1_sort < hatch_sort, + "second BACK must land strictly below the first ({line1_sort} vs {hatch_sort})" + ); + } + + // Regression test for a suspected multi-select tie in DRAWORDER + // ABOVE/UNDER: the loop called move_above/move_below per entity, and each + // call recomputes target±1, so N selected entities should all collapse to + // one key. This test must fail while that bug exists. + // Same floor-exhaustion scenario as hatchtoback_exhausted_floor_…, but + // through the interactive DRAWORDER BACK path: two selected lines must + // land distinct and strictly behind a sibling pinned at key 2, instead of + // both clamping onto key 1. + #[test] + fn draworder_back_exhausted_floor_keeps_distinct_and_behind() { + let mut app = fresh_app(); + let i = app.active_tab; + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let h_pinned = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_line2 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + + { + use acadrust::objects::{ObjectType, SortEntitiesTable}; + let doc = &mut app.tabs[i].scene.document; + let nh = acadrust::Handle::new(doc.next_handle()); + let mut table = SortEntitiesTable::for_block(block_handle); + table.handle = nh; + table.add_entry(h_pinned, acadrust::Handle::new(2)); + doc.objects.insert(nh, ObjectType::SortEntitiesTable(table)); + } + + app.tabs[i] + .scene + .replace_selection([h_line1, h_line2].into_iter().collect()); + let _ = app.run_command_line("DRAWORDER BACK"); + + let entries = effective_sort_map(&app); + let pinned_sort = entries.get(&h_pinned.value()).copied().unwrap_or(h_pinned.value()); + let s1 = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); + let s2 = entries.get(&h_line2.value()).copied().unwrap_or(h_line2.value()); + + assert_ne!(s1, s2, "BACK must not clamp both selected entities onto one tied key"); + assert!( + pinned_sort > s1.max(s2), + "pinned sibling ({pinned_sort}) must stay above the moved pair ({s1}, {s2})" + ); + } + + #[test] + fn draworder_above_multi_select_keeps_distinct_order() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch1 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_hatch2 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + app.tabs[i] + .scene + .replace_selection([h_hatch1, h_hatch2].into_iter().collect()); + let cmd = format!("DRAWORDER ABOVE {:x}", h_ref.value()); + let _ = app.run_command_line(&cmd); + + let entries = effective_sort_map(&app); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + let hatch1_sort = entries.get(&h_hatch1.value()).copied().unwrap_or(h_hatch1.value()); + let hatch2_sort = entries.get(&h_hatch2.value()).copied().unwrap_or(h_hatch2.value()); + + assert!(hatch1_sort > ref_sort, "hatch 1 ({hatch1_sort}) must be above reference ({ref_sort})"); + assert!(hatch2_sort > ref_sort, "hatch 2 ({hatch2_sort}) must be above reference ({ref_sort})"); + assert_ne!(hatch1_sort, hatch2_sort, "multi-select ABOVE must not tie selected entities"); + assert!( + hatch2_sort > hatch1_sort, + "selection order must be preserved within the moved group ({hatch1_sort}, {hatch2_sort})" + ); + } + + #[test] + fn draworder_under_multi_select_keeps_distinct_order() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch1 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_hatch2 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + app.tabs[i] + .scene + .replace_selection([h_hatch1, h_hatch2].into_iter().collect()); + let cmd = format!("DRAWORDER UNDER {:x}", h_ref.value()); + let _ = app.run_command_line(&cmd); + + let entries = effective_sort_map(&app); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + let hatch1_sort = entries.get(&h_hatch1.value()).copied().unwrap_or(h_hatch1.value()); + let hatch2_sort = entries.get(&h_hatch2.value()).copied().unwrap_or(h_hatch2.value()); + + assert!(hatch1_sort < ref_sort, "hatch 1 ({hatch1_sort}) must be under reference ({ref_sort})"); + assert!(hatch2_sort < ref_sort, "hatch 2 ({hatch2_sort}) must be under reference ({ref_sort})"); + assert_ne!(hatch1_sort, hatch2_sort, "multi-select UNDER must not tie selected entities"); + assert!( + hatch1_sort > hatch2_sort, + "selection order must be preserved within the moved group ({hatch1_sort}, {hatch2_sort})" + ); + } + + #[test] + fn draworder_front_moves_hatch_in_front_of_all() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_line2 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + + app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); + let _ = app.run_command_line("DRAWORDER FRONT"); + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line1_sort = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); + let line2_sort = entries.get(&h_line2.value()).copied().unwrap_or(h_line2.value()); + + assert!(hatch_sort > line1_sort, "hatch ({hatch_sort}) must render in front of line 1 ({line1_sort})"); + assert!(hatch_sort > line2_sort, "hatch ({hatch_sort}) must render in front of line 2 ({line2_sort})"); + } + + #[test] + fn draworder_back_moves_hatch_behind_all() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_line2 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + + app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); + let _ = app.run_command_line("DRAWORDER BACK"); + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line1_sort = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); + let line2_sort = entries.get(&h_line2.value()).copied().unwrap_or(h_line2.value()); + + assert!(hatch_sort < line1_sort, "hatch ({hatch_sort}) must render behind line 1 ({line1_sort})"); + assert!(hatch_sort < line2_sort, "hatch ({hatch_sort}) must render behind line 2 ({line2_sort})"); + } + + #[test] + fn draworder_above_reference_puts_hatch_in_front_of_object() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); + let cmd = format!("DRAWORDER ABOVE {:x}", h_ref.value()); + let _ = app.run_command_line(&cmd); + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + + assert!(hatch_sort > ref_sort, "hatch ({hatch_sort}) must be above reference object ({ref_sort})"); + } + + #[test] + fn draworder_under_reference_puts_hatch_behind_object() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); + let cmd = format!("DRAWORDER UNDER {:x}", h_ref.value()); + let _ = app.run_command_line(&cmd); + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + + assert!(hatch_sort < ref_sort, "hatch ({hatch_sort}) must be under reference object ({ref_sort})"); + } + + #[test] + fn draworder_interactive_shortcuts_f_b_a_u() { + use crate::command::CadCommand; + + let mut app = fresh_app(); + let i = app.active_tab; + let _h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // 1. Shortcut 'F' -> Front + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + assert_eq!(cmd.wants_text_input(), true); + let res = cmd.on_text_input("F"); + match res { + Some(crate::command::CmdResult::Relaunch(c, handles)) => { + assert_eq!(c, "DRAWORDER FRONT"); + assert_eq!(handles, vec![h_hatch]); + } + _ => panic!("Expected Relaunch for shortcut F"), + } + + // 2. Shortcut 'B' -> Back + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + let res = cmd.on_text_input("B"); + match res { + Some(crate::command::CmdResult::Relaunch(c, handles)) => { + assert_eq!(c, "DRAWORDER BACK"); + assert_eq!(handles, vec![h_hatch]); + } + _ => panic!("Expected Relaunch for shortcut B"), + } + + // 3. Shortcut 'A' -> Advances to PickReference (above) + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + assert!(!cmd.needs_entity_pick()); + let res = cmd.on_text_input("A"); + assert!(matches!(res, Some(crate::command::CmdResult::NeedPoint))); + assert!(cmd.needs_entity_pick(), "Needs entity pick after choosing 'A'"); + + // 4. Shortcut 'U' -> Advances to PickReference (under) + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + let res = cmd.on_text_input("U"); + assert!(matches!(res, Some(crate::command::CmdResult::NeedPoint))); + assert!(cmd.needs_entity_pick(), "Needs entity pick after choosing 'U'"); + } + + #[test] + fn draworder_interactive_above_under_viewport_entity_pick() { + use crate::command::CadCommand; + + let mut app = fresh_app(); + let i = app.active_tab; + let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // Above with viewport pick + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + let _ = cmd.on_text_input("A"); + assert!(cmd.needs_entity_pick()); + let pick_res = cmd.on_entity_pick(h_ref, glam::DVec3::ZERO); + match pick_res { + crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => { + assert_eq!(relaunch_cmd, format!("DRAWORDER A {:x}", h_ref.value())); + assert_eq!(handles, vec![h_hatch]); + app.tabs[i].scene.replace_selection(handles.into_iter().collect()); + let _ = app.run_command_line(&relaunch_cmd); + } + _ => panic!("Expected Relaunch from entity pick"), + } + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + assert!(hatch_sort > ref_sort, "Hatch must be above reference after viewport pick"); + + // Under with viewport pick + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + let _ = cmd.on_text_input("U"); + assert!(cmd.needs_entity_pick()); + let pick_res = cmd.on_entity_pick(h_ref, glam::DVec3::ZERO); + match pick_res { + crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => { + assert_eq!(relaunch_cmd, format!("DRAWORDER U {:x}", h_ref.value())); + assert_eq!(handles, vec![h_hatch]); + app.tabs[i].scene.replace_selection(handles.into_iter().collect()); + let _ = app.run_command_line(&relaunch_cmd); + } + _ => panic!("Expected Relaunch from entity pick"), + } + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + assert!(hatch_sort < ref_sort, "Hatch must be under reference after viewport pick"); + } + + #[test] + fn draworder_interactive_typed_hex_handle() { + use crate::command::CadCommand; + + let mut app = fresh_app(); + let i = app.active_tab; + let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + let _ = cmd.on_text_input("Above"); + let hex_input = format!("0x{:x}", h_ref.value()); + let typed_res = cmd.on_text_input(&hex_input); + match typed_res { + Some(crate::command::CmdResult::Relaunch(relaunch_cmd, handles)) => { + assert_eq!(relaunch_cmd, format!("DRAWORDER A {:x}", h_ref.value())); + assert_eq!(handles, vec![h_hatch]); + app.tabs[i].scene.replace_selection(handles.into_iter().collect()); + let _ = app.run_command_line(&relaunch_cmd); + } + _ => panic!("Expected Relaunch from typed hex handle"), + } + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + assert!(hatch_sort > ref_sort); + } + + #[test] + fn draworder_interactive_without_preselection() { + use crate::command::CadCommand; + + let mut app = fresh_app(); + let i = app.active_tab; + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // Start command with no pre-selection + let mut cmd = DrawOrderCommand::new(vec![]); + assert!(cmd.is_selection_gathering()); + assert!(!cmd.wants_text_input()); + + // Gather selection + let _ = cmd.on_selection_complete(vec![h_hatch]); + let enter_res = cmd.on_enter(); + assert!(matches!(enter_res, crate::command::CmdResult::NeedPoint)); + assert!(!cmd.is_selection_gathering()); + assert!(cmd.wants_text_input()); + + // Choose verb Front + let res = cmd.on_text_input("F"); + match res { + Some(crate::command::CmdResult::Relaunch(relaunch_cmd, handles)) => { + assert_eq!(relaunch_cmd, "DRAWORDER FRONT"); + assert_eq!(handles, vec![h_hatch]); + app.tabs[i].scene.replace_selection(handles.into_iter().collect()); + let _ = app.run_command_line(&relaunch_cmd); + } + _ => panic!("Expected Relaunch"), + } + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + assert!(hatch_sort > line_sort); } } diff --git a/src/app/document.rs b/src/app/document.rs index 09048da4..3c866fac 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -195,10 +195,6 @@ pub(super) struct DocumentTab { pub(super) active_mleader_style: String, /// Last camera_generation value written back to the document. pub(super) last_synced_camera_gen: u64, - /// Render-state key of `scene.document.preview`. Matching saves reuse the - /// encoded DWG thumbnail instead of rescanning every resident wire. - #[cfg(not(target_arch = "wasm32"))] - pub(super) thumbnail_cache_key: Option, /// Sentinel "Welcome / Start" tab. Always at index 0 when present. /// Cannot be closed; the viewport area renders a welcome page instead /// of the model-space shader. The scene is still constructed so the @@ -260,12 +256,11 @@ impl DocumentTab { .unwrap_or(glam::DVec3::ZERO) } - /// True when the active pane edits **model-space** geometry: the Model tab, - /// or inside a floating viewport (MSPACE). The UCS applies in both; plain - /// paper space (no active viewport) is excluded. Single predicate so every - /// UCS-aware system shares one rule. [[feedback_shared_infra]] + /// True when the active pane has a drawing coordinate plane. pub(super) fn editing_model_space(&self) -> bool { - self.scene.current_layout == "Model" || self.scene.active_viewport.is_some() + self.scene.current_layout == "Model" + || self.scene.active_viewport.is_some() + || self.active_ucs.is_some() } /// The document's saved model-space UCS (header), as a `Ucs`. `None` when it @@ -325,9 +320,51 @@ impl DocumentTab { } } + pub(super) fn ucs_from_layout(&self) -> Option { + let layout = self.scene.document.objects.values().find_map(|object| { + let acadrust::objects::ObjectType::Layout(layout) = object else { + return None; + }; + (layout.name == self.scene.current_layout).then_some(layout) + })?; + let mut ucs = self + .scene + .document + .ucss + .iter() + .find(|ucs| ucs.handle == layout.named_ucs) + .cloned() + .unwrap_or_else(|| Ucs::new("*PAPERUCS*")); + ucs.origin = acadrust::types::Vector3::new( + layout.ucs_origin.0, + layout.ucs_origin.1, + layout.ucs_origin.2, + ); + ucs.x_axis = acadrust::types::Vector3::new( + layout.ucs_x_axis.0, + layout.ucs_x_axis.1, + layout.ucs_x_axis.2, + ); + ucs.y_axis = acadrust::types::Vector3::new( + layout.ucs_y_axis.0, + layout.ucs_y_axis.1, + layout.ucs_y_axis.2, + ); + ucs.elevation = layout.elevation; + ucs.ortho_type = layout.ucs_ortho_type; + ucs.named_ucs_handle = layout.named_ucs; + ucs.base_ucs_handle = layout.base_ucs; + let is_world = layout.named_ucs.is_null() + && layout.base_ucs.is_null() + && layout.ucs_ortho_type == 0 + && layout.elevation.abs() <= f64::EPSILON + && super::helpers::UcsXform::from_ucs(&ucs).is_identity(); + (!is_world).then_some(ucs) + } + /// Set `active_ucs` to the UCS of the *current pane*: the entered viewport's - /// own per-viewport UCS, the model header UCS in the Model tab, or none in - /// plain paper space. Keeps the ViewCube in lock-step. Call on every pane + /// own per-viewport UCS, the model header UCS, or the layout UCS. Keeps the + /// ViewCube in lock-step. Call on every pane /// change (enter/exit viewport, layout / tab switch, load) so one field /// drives all UCS-aware systems regardless of where editing happens. pub(super) fn refresh_active_ucs(&mut self) { @@ -338,15 +375,52 @@ impl DocumentTab { } else if self.scene.current_layout == "Model" { self.model_ucs_from_header() } else { - None + self.ucs_from_layout() }; + if self.active_block_edit.is_none() + && self.scene.active_viewport.is_none() + && self.scene.current_layout != "Model" + { + self.sync_paper_ucs_header(); + } self.sync_ucs_to_scene(); } + fn sync_paper_ucs_header(&mut self) { + use acadrust::types::Vector3; + let header = &mut self.scene.document.header; + match &self.active_ucs { + Some(ucs) => { + header.paper_space_ucs_origin = ucs.origin; + header.paper_space_ucs_x_axis = ucs.x_axis; + header.paper_space_ucs_y_axis = ucs.y_axis; + header.paper_elevation = ucs.elevation; + header.paper_space_ucs_name = if (ucs.named_ucs_handle.is_valid() + || ucs.handle.is_valid()) + && !ucs.name.starts_with('*') + { + ucs.name.clone() + } else { + String::new() + }; + header.paper_ucs_ortho_ref = ucs.base_ucs_handle; + header.paper_ucs_ortho_view = ucs.ortho_type; + } + None => { + header.paper_space_ucs_origin = Vector3::ZERO; + header.paper_space_ucs_x_axis = Vector3::UNIT_X; + header.paper_space_ucs_y_axis = Vector3::UNIT_Y; + header.paper_elevation = 0.0; + header.paper_space_ucs_name.clear(); + header.paper_ucs_ortho_ref = Handle::NULL; + header.paper_ucs_ortho_view = 0; + } + } + } + /// Persist `active_ucs` back to its pane's storage so it round-trips: the /// entered viewport's per-viewport UCS fields, or the document header's - /// model-space UCS in the Model tab. No-op in plain paper space. Call after - /// any UCS change. + /// model-space UCS in the Model tab, or the layout UCS. Call after a change. pub(super) fn persist_active_ucs(&mut self) { use acadrust::types::Vector3; if let Some(index) = self.active_block_edit { @@ -389,6 +463,48 @@ impl DocumentTab { h.model_space_ucs_name.clear(); } } + } else { + let (origin, x_axis, y_axis, elevation, ortho, named, base) = + match &self.active_ucs { + Some(ucs) => ( + ucs.origin, + ucs.x_axis, + ucs.y_axis, + ucs.elevation, + ucs.ortho_type, + if ucs.named_ucs_handle.is_valid() { + ucs.named_ucs_handle + } else { + ucs.handle + }, + ucs.base_ucs_handle, + ), + None => ( + Vector3::ZERO, + Vector3::UNIT_X, + Vector3::UNIT_Y, + 0.0, + 0, + Handle::NULL, + Handle::NULL, + ), + }; + for object in self.scene.document.objects.values_mut() { + let acadrust::objects::ObjectType::Layout(layout) = object else { + continue; + }; + if layout.name == self.scene.current_layout { + layout.ucs_origin = (origin.x, origin.y, origin.z); + layout.ucs_x_axis = (x_axis.x, x_axis.y, x_axis.z); + layout.ucs_y_axis = (y_axis.x, y_axis.y, y_axis.z); + layout.elevation = elevation; + layout.ucs_ortho_type = ortho; + layout.named_ucs = named; + layout.base_ucs = base; + break; + } + } + self.sync_paper_ucs_header(); } } @@ -486,8 +602,6 @@ impl DocumentTab { active_block_edit: None, active_mleader_style: "Standard".to_string(), last_synced_camera_gen: 0, - #[cfg(not(target_arch = "wasm32"))] - thumbnail_cache_key: None, is_start: false, pan_mode: false, orbit_mode: false, diff --git a/src/app/layers.rs b/src/app/layers.rs index 6f00ce26..6be69c8f 100644 --- a/src/app/layers.rs +++ b/src/app/layers.rs @@ -143,15 +143,22 @@ impl OpenCADStudio { } }) .collect(); - let active_mleader = self.tabs[i].active_mleader_style.clone(); - let active_mleader = if mleader_names.contains(&active_mleader) { - active_mleader - } else { + let current_mleader = + doc.header.current_mleader_style_name.clone(); + + let active_mleader = mleader_names - .first() + .iter() + .find(|name| { + name.eq_ignore_ascii_case( + ¤t_mleader + ) + }) .cloned() - .unwrap_or_default() - }; + .or_else(|| { + mleader_names.first().cloned() + }) + .unwrap_or_default(); let table_names: Vec = doc .objects diff --git a/src/app/mod.rs b/src/app/mod.rs index 76f1d281..e6b9a116 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, /// Working copy of the Drawing Units dialog; `None` while it is closed. drawing_units: Option, + /// Working copy of the structured feature-control-frame editor. + geometric_tolerance: Option, /// PICKDRAG (#226): false (default) = press-drag lassoes; true = /// press-drag draws a rectangle marquee. pick_drag_rect: bool, @@ -477,6 +480,18 @@ pub(super) struct OpenCADStudio { /// The `a` channel is always 1.0. default_bg_color: Option<[f32; 4]>, default_paper_bg_color: Option<[f32; 4]>, + /// CLIPROMPTLINES: how many temporary prompt lines for a single command + /// are displayed above the command window (0–50, Registry, default 3). + cliprompt_lines: i32, + /// MRU list of block names inserted via INSERT, most recent first, capped to 20. + block_mru: Vec, + /// Insertion frequency per block name (uppercase key → count), capped. + block_freq: std::collections::HashMap, + /// Last time block-usage was flushed to disk (debounce per 2.4). + #[cfg(not(target_arch = "wasm32"))] + block_usage_last_persist: Option, + #[cfg(target_arch = "wasm32")] + block_usage_last_persist: Option<()>, /// `true` after a bare `VPORTS` in model space — the next command-line /// entry is treated as the tiled-config option (SIngle/2H/2V/4). awaiting_vports: bool, @@ -608,6 +623,12 @@ pub(super) struct OpenCADStudio { /// OS window Id for the floating Layer Properties Manager (None when closed). /// OS window Id of the primary application window. main_window: Option, + /// Hides drawing UI overlays for one thumbnail capture frame. + thumbnail_capture_clean: bool, + #[cfg(not(target_arch = "wasm32"))] + pending_native_thumbnail_save: Option, + #[cfg(target_arch = "wasm32")] + pending_web_thumbnail_save: Option, // ── Floating panel windows ──────────────────────────────────────────── /// Active `iced_aw` colour picker: destination plus its initial colour. color_pick_target: Option<(ColorPickTarget, AcadColor)>, @@ -673,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, + /// `(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, @@ -758,6 +783,9 @@ pub(super) struct OpenCADStudio { ctrl_down: bool, /// Open in-place MText editor (toolbar + text area + live preview), if any. mtext_editor: Option, + /// Return rich text to a suspended drawing command. + command_mtext_input: bool, + pending_command_editor_text: Option, /// Open in-place single-line TEXT editor (plain text-entry box), if any. text_inline: Option, /// Cursor-anchored one-shot snap override menu (Shift+RMB): the canvas @@ -789,16 +817,21 @@ pub(super) struct OpenCADStudio { /// Snapshot of the dialog's settings taken when it opened, restored by the /// `` list entry. plot_prev: Option, + /// Full source settings behind the fields currently shown in Plot. + plot_setup_template: Option, /// Paper layouts shown by Print All, in tab order with their selection. print_all_layouts: Vec<(String, bool)>, /// True while the Plot dialog is editing settings for Print All. print_all_options: bool, + /// True when Print All should override each layout's page setup. + print_all_settings_override: bool, /// Settings restored when the Print All options dialog is cancelled. print_all_options_prev: Option, /// Plot style restored together with cancelled Print All options. print_all_plot_style_prev: Option>, /// Plot window restored together with cancelled Print All options. print_all_plot_window_prev: Option>, + print_all_plot_setup_prev: Option>, // ── Plot Style Table ────────────────────────────────────────────────── /// Currently loaded CTB/STB table (None = no override). @@ -1135,6 +1168,28 @@ pub(super) enum SaveContinuation { Quit, } +#[derive(Debug, Clone)] +#[cfg(not(target_arch = "wasm32"))] +pub(super) struct PendingNativeThumbnailSave { + tab_id: u64, + path: PathBuf, + version: acadrust::DxfVersion, + purpose: SavePurpose, + continuation: SaveContinuation, + set_current_path: bool, + check_external_change: bool, +} + +#[derive(Debug, Clone)] +#[cfg(target_arch = "wasm32")] +pub(super) struct PendingWebThumbnailSave { + tab_id: u64, + filename: String, + ext: String, + version: acadrust::DxfVersion, + bounds: iced::Rectangle, +} + #[derive(Debug, Clone)] #[cfg(not(target_arch = "wasm32"))] pub(super) struct PendingSaveFailure { @@ -1158,16 +1213,6 @@ pub(super) struct PendingExternalChange { set_current_path: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg(not(target_arch = "wasm32"))] -pub(super) struct ThumbnailCacheKey { - epoch: u64, - camera_generation: u64, - bg_color: [u32; 4], - png: bool, - viewport: [u32; 2], -} - #[derive(Debug, Clone)] #[cfg(not(target_arch = "wasm32"))] pub struct SaveOutcome { @@ -1182,7 +1227,6 @@ pub struct SaveOutcome { set_current_path: bool, purpose: SavePurpose, continuation: SaveContinuation, - thumbnail_key: Option, refreshed_preview: Option>, result: Result<(), crate::io::SaveFailure>, } @@ -1205,6 +1249,8 @@ pub enum ColorPickTarget { Properties, /// Selected entities' background colour (hatch / MTEXT background row). PropertiesBg, + /// A named per-entity Properties colour field. + PropertiesField(String), /// Current creation colour (ribbon). Ribbon, /// A layer's colour, by panel row index. @@ -1553,6 +1599,7 @@ pub enum ModalKind { LayerStateManager, LayerTranslator, DrawingUnits, + GeometricTolerance, DraftingSettings, LayerStateEditor, Plot, @@ -1937,9 +1984,23 @@ pub enum Message { AecDropBack, /// Periodic autosave tick — write `.sv$` recovery files for dirty tabs. AutoSave, + /// A clean viewport frame is ready for thumbnail capture. + ThumbnailCaptureFrame, + /// Restore drawing UI after the compositor screenshot is captured. + ThumbnailCaptureFinished, /// Native background save/autosave completed. #[cfg(not(target_arch = "wasm32"))] SaveFinished(SaveOutcome), + /// Web viewport capture completed; serialize and download the drawing. + #[cfg(target_arch = "wasm32")] + WebSaveScreenshot { + tab_id: u64, + filename: String, + ext: String, + version: acadrust::DxfVersion, + bounds: Option, + screenshot: Option, + }, /// Retry the failed save after the other application releases the file. #[cfg(not(target_arch = "wasm32"))] SaveFileInUseRetry, @@ -2287,6 +2348,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. @@ -2624,6 +2693,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), @@ -3114,6 +3208,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, @@ -3142,6 +3237,10 @@ impl OpenCADStudio { savetime_min: 10, default_bg_color: None, default_paper_bg_color: None, + cliprompt_lines: 3, + block_mru: Vec::new(), + block_freq: std::collections::HashMap::new(), + block_usage_last_persist: None, awaiting_vports: false, pending_setvar: None, ucs_icon_hover: false, @@ -3183,6 +3282,11 @@ impl OpenCADStudio { show_layout_tabs: true, last_point: None, main_window: None, + thumbnail_capture_clean: false, + #[cfg(not(target_arch = "wasm32"))] + pending_native_thumbnail_save: None, + #[cfg(target_arch = "wasm32")] + pending_web_thumbnail_save: None, color_pick_target: None, color_picker_tab: ColorPickerTab::Index, recent_colors: Vec::new(), @@ -3206,6 +3310,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(), @@ -3236,6 +3342,8 @@ impl OpenCADStudio { ctrl_down: false, cont_anchor: None, mtext_editor: None, + command_mtext_input: false, + pending_command_editor_text: None, text_inline: None, snap_override_popup: None, axis_lock_dir: None, @@ -3250,11 +3358,14 @@ impl OpenCADStudio { plot_orientation: crate::io::paper_sizes::Orientation::Landscape, plot_dialog: crate::ui::window::plot::PlotDialogState::default(), plot_prev: None, + plot_setup_template: None, print_all_layouts: Vec::new(), print_all_options: false, + print_all_settings_override: false, print_all_options_prev: None, print_all_plot_style_prev: None, print_all_plot_window_prev: None, + print_all_plot_setup_prev: None, opening: None, open_job_serial: 0, recovery_report: None, diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 2c67604a..2fe32100 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -1,15 +1,9 @@ -// 3D solid modelling support on the App: committing Model-tab primitives, -// and the Design-group boolean operations over the scene's session-cached -// B-reps. -// -// The bodies come from the geometry kernel, so every operation here is on -// analytic surfaces rather than on facets — a turned cylinder is still a -// cylinder afterwards, and saves as one. +// Kernel B-rep solid modelling and exact ACIS persistence. -use acadrust::entities::Solid3D; -use acadrust::objects::SolidHistoryOperation; +use acadrust::{ + entities::Solid3D, objects::SolidHistoryOperation, EntityType, Handle, +}; use cadkernel::brep::Body; -use acadrust::{EntityType, Handle}; use iced::Task; use super::Message; @@ -18,16 +12,25 @@ use crate::scene::model::solid_history; use crate::scene::model::solid_model::{self, Bool}; impl super::OpenCADStudio { - /// Commit a Model-tab solid: add its acadrust entity to the document, then - /// register the B-rep (caches it for booleans + tessellates it into the - /// shaded mesh pipeline). Returns the new entity handle. + /// Add a solid and register its persistent B-rep. pub(super) fn add_solid_model( &mut self, - entity: EntityType, + mut entity: EntityType, solid: Body, history: SolidHistoryOperation, ) -> Handle { let i = self.active_tab; + let EntityType::Solid3D(inner) = &mut entity else { + return Handle::NULL; + }; + inner.wires = solid_model::edge_wires(&solid); + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&solid) + else { + self.command_line + .push_error(crate::t!("The solid could not be encoded as ACIS.").as_ref()); + return Handle::NULL; + }; + inner.set_sat_document(&document); let Some(handle) = self.commit_entity_handle(entity) else { return Handle::NULL; }; @@ -36,42 +39,202 @@ impl super::OpenCADStudio { handle } - /// Run a boolean (`union` / `subtract` / `intersect`) on exactly two - /// selected solids whose B-reps are in the session cache. - pub(super) fn solid_boolean(&mut self, op: BoolOp) -> Task { + fn selected_solid_handles(&mut self) -> Vec { let i = self.active_tab; - // Selected entities that have a cached B-rep. - let handles: Vec = self.tabs[i] + let mut handles: Vec = self.tabs[i] .scene .selected_handles_in_order() .into_iter() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .filter(|handle| { + matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some(EntityType::Solid3D(_)) + ) + }) .collect(); - if handles.len() != 2 { + self.tabs[i].scene.restore_solid_models(&handles); + handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + handles + } + + fn replace_solid_body(&mut self, handle: Handle, result: Body, label: &str) -> bool { + let i = self.active_tab; + let Some(document) = + crate::scene::convert::acis_export::solid_to_sat(&result) + else { self.command_line - .push_error(crate::t!("Boolean: select exactly two solids created this session.").as_ref()); + .push_error(crate::t!("The result could not be encoded as ACIS.").as_ref()); + return false; + }; + let Some(EntityType::Solid3D(mut entity)) = + self.tabs[i].scene.document.get_entity(handle).cloned() + else { + return false; + }; + entity.wires = solid_model::edge_wires(&result); + entity.silhouettes.clear(); + entity.history_handle = None; + entity.set_sat_document(&document); + + self.push_undo_snapshot(i, label); + self.tabs[i].scene.delete_solid_history(handle); + if !self.tabs[i] + .scene + .update_entity(EntityType::Solid3D(entity)) + { + self.command_line + .push_error(crate::t!("The solid could not be updated.").as_ref()); + return false; + } + let history = solid_history::brep_op(&result); + self.tabs[i].scene.create_solid_history(handle, history); + self.tabs[i].scene.register_solid_model(handle, result); + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(handle, false); + self.tabs[i].dirty = true; + self.refresh_properties(); + true + } + + pub(super) fn solid_edge_blend( + &mut self, + handle: Handle, + pick: glam::DVec3, + value: f64, + fillet: bool, + ) -> Task { + let i = self.active_tab; + if self.reject_locked_edit(i, handle) { + return Task::none(); + } + if !matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(EntityType::Solid3D(_)) + ) { + self.command_line + .push_error(crate::t!("Select a 3D solid edge.").as_ref()); + return Task::none(); + } + self.tabs[i].scene.restore_solid_models(&[handle]); + let Some(body) = self.tabs[i].scene.solid_models.get(&handle).cloned() else { + self.command_line + .push_error(crate::t!("The solid geometry could not be restored.").as_ref()); + return Task::none(); + }; + let Some(edge) = solid_model::nearest_edge(&body, pick.to_array()) else { + self.command_line + .push_error(crate::t!("Select a solid edge.").as_ref()); + return Task::none(); + }; + let result = if fillet { + cadkernel::brep::fillet(&body, edge, value) + } else { + cadkernel::brep::chamfer(&body, edge, value) + }; + let Some(result) = result else { + self.command_line.push_error( + crate::t!("Edge operation failed. Use a convex planar solid and a smaller value.") + .as_ref(), + ); + return Task::none(); + }; + let label = if fillet { "SOLIDFILLET" } else { "SOLIDCHAMFER" }; + if self.replace_solid_body(handle, result, label) { + self.command_line + .push_output(crate::tf!("{label}: solid updated.").as_ref()); + } + Task::none() + } + + pub(super) fn solid_face_presspull( + &mut self, + handle: Handle, + pick: glam::DVec3, + distance: f64, + drag: Option, + ) -> Task { + let i = self.active_tab; + if self.reject_locked_edit(i, handle) { + return Task::none(); + } + self.tabs[i].scene.restore_solid_models(&[handle]); + let Some(body) = self.tabs[i].scene.solid_models.get(&handle).cloned() else { + self.command_line + .push_error(crate::t!("The solid geometry could not be restored.").as_ref()); + return Task::none(); + }; + let Some(face) = solid_model::nearest_planar_face(&body, pick.to_array()) else { + self.command_line + .push_error(crate::t!("Select a planar solid face.").as_ref()); + return Task::none(); + }; + let distance = match drag { + Some(point) => { + let Some(normal) = solid_model::planar_face_normal(&body, face) else { + self.command_line + .push_error(crate::t!("Select a planar solid face.").as_ref()); + return Task::none(); + }; + (point - pick).dot(glam::DVec3::from_array(normal)) + } + None => distance, + }; + if !distance.is_finite() || distance.abs() <= 1e-6 { + self.command_line.push_error( + crate::t!("PRESSPULL: drag along the selected face normal.").as_ref(), + ); + return Task::none(); + } + let Some(result) = cadkernel::brep::presspull(&body, face, distance) else { + self.command_line.push_error( + crate::t!("PRESSPULL: the face move would collapse or invalidate the solid.") + .as_ref(), + ); + return Task::none(); + }; + if self.replace_solid_body(handle, result, "PRESSPULL") { + self.command_line + .push_output(crate::t!("PRESSPULL: solid updated.").as_ref()); + } + Task::none() + } + + /// Run a boolean over the selected solids in selection order. + pub(super) fn solid_boolean(&mut self, op: BoolOp) -> Task { + let i = self.active_tab; + let handles = self.selected_solid_handles(); + if handles.len() < 2 { + self.command_line + .push_error(crate::t!("Boolean: select at least two solids.").as_ref()); return Task::none(); } - let a = self.tabs[i].scene.solid_models[&handles[0]].clone(); - let b = self.tabs[i].scene.solid_models[&handles[1]].clone(); let kind = match op { BoolOp::Union => Bool::Union, BoolOp::Subtract => Bool::Subtract, BoolOp::Intersect => Bool::Intersect, }; - let Some(result) = solid_model::boolean(kind, &a, &b) else { + let mut operands = handles + .iter() + .filter_map(|handle| self.tabs[i].scene.solid_models.get(handle).cloned()); + let mut result = operands.next().expect("at least two restored solids"); + for operand in operands { + let Some(combined) = solid_model::boolean(kind, &result, &operand) else { + self.command_line.push_error( + crate::t!("Boolean failed while combining the selected solids.").as_ref(), + ); + return Task::none(); + }; + result = combined; + } + if crate::scene::convert::acis_export::solid_to_sat(&result).is_none() { self.command_line - .push_error(crate::t!("Boolean failed — the solids may not overlap.").as_ref()); + .push_error(crate::t!("The boolean result could not be encoded as ACIS.").as_ref()); return Task::none(); - }; + } self.push_undo_snapshot(i, "BOOLEAN"); - // Remove the two operands (entity + mesh + cached B-rep). self.tabs[i].scene.erase_entities(&handles); - // The result is freshly combined geometry with no ACIS parametrisation, - // so it lives as a Solid3D whose render/boolean data is the injected - // mesh + cached B-rep; its edge wires make it pickable. let mut s3d = Solid3D::new(); s3d.wires = solid_model::edge_wires(&result); let history = solid_history::brep_op(&result); @@ -85,20 +248,87 @@ impl super::OpenCADStudio { Task::none() } + pub(super) fn solid_subtract( + &mut self, + bases: &[Handle], + cutters: &[Handle], + ) -> Task { + let i = self.active_tab; + let valid_solid = |handle: &Handle| { + !self.tabs[i].scene.is_layer_locked(*handle) + && matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some(EntityType::Solid3D(_)) + ) + }; + let mut base_handles: Vec<_> = bases.iter().copied().filter(valid_solid).collect(); + let mut cutter_handles: Vec<_> = cutters.iter().copied().filter(valid_solid).collect(); + cutter_handles.retain(|handle| !base_handles.contains(handle)); + + let mut operands = base_handles.clone(); + operands.extend(cutter_handles.iter().copied()); + self.tabs[i].scene.restore_solid_models(&operands); + base_handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + cutter_handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + if base_handles.is_empty() || cutter_handles.is_empty() { + self.command_line.push_error( + crate::t!("SUBTRACT: select at least one base solid and one cutter solid.") + .as_ref(), + ); + return Task::none(); + } + + let mut result = self.tabs[i].scene.solid_models[&base_handles[0]].clone(); + for handle in &base_handles[1..] { + let operand = &self.tabs[i].scene.solid_models[handle]; + let Some(combined) = solid_model::boolean(Bool::Union, &result, operand) else { + self.command_line.push_error( + crate::t!("SUBTRACT failed while combining the base solids.").as_ref(), + ); + return Task::none(); + }; + result = combined; + } + for handle in &cutter_handles { + let operand = &self.tabs[i].scene.solid_models[handle]; + let Some(difference) = solid_model::boolean(Bool::Subtract, &result, operand) else { + self.command_line.push_error( + crate::t!("SUBTRACT failed while removing the selected solids.").as_ref(), + ); + return Task::none(); + }; + result = difference; + } + if crate::scene::convert::acis_export::solid_to_sat(&result).is_none() { + self.command_line + .push_error(crate::t!("The boolean result could not be encoded as ACIS.").as_ref()); + return Task::none(); + } + + operands = base_handles; + operands.extend(cutter_handles); + self.push_undo_snapshot(i, "SUBTRACT"); + self.tabs[i].scene.erase_entities(&operands); + let mut entity = Solid3D::new(); + entity.wires = solid_model::edge_wires(&result); + let history = solid_history::brep_op(&result); + let handle = self.add_solid_model(EntityType::Solid3D(entity), result, history); + self.tabs[i].scene.deselect_all(); + if !handle.is_null() { + self.tabs[i].scene.select_entity(handle, false); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + Task::none() + } + /// Slice the one selected solid with an axis-aligned plane (axis 0/1/2 = /// X/Y/Z at `value`), keeping the lower side when `keep_low` is true. The /// kept half is the intersection of the solid with a half-space box, reusing - /// the same boolean path the Design-group tools use. + /// the same boolean path as the modelling tools. pub(super) fn solid_slice(&mut self, axis: usize, value: f64, keep_low: bool) -> Task { let i = self.active_tab; - let handles: Vec = self.tabs[i] - .scene - .selected - .iter() - .copied() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) - .collect(); + let handles = self.selected_solid_handles(); if handles.len() != 1 { self.command_line .push_error(crate::t!("SLICE: select exactly one solid created this session.").as_ref()); @@ -167,14 +397,7 @@ impl super::OpenCADStudio { /// leaving the originals in place (a non-destructive boolean intersect). pub(super) fn solid_interfere(&mut self) -> Task { let i = self.active_tab; - let handles: Vec = self.tabs[i] - .scene - .selected - .iter() - .copied() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) - .collect(); + let handles = self.selected_solid_handles(); if handles.len() != 2 { self.command_line .push_error(crate::t!("INTERFERE: select exactly two solids created this session.").as_ref()); @@ -207,14 +430,7 @@ impl super::OpenCADStudio { /// orientation, so it reuses the cached B-rep directly. pub(super) fn solid_rotate3d(&mut self, axis: usize, angle_deg: f64) -> Task { let i = self.active_tab; - let handles: Vec = self.tabs[i] - .scene - .selected - .iter() - .copied() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) - .collect(); + let handles = self.selected_solid_handles(); if handles.len() != 1 { self.command_line .push_error(crate::t!("3DROTATE: select exactly one solid created this session.").as_ref()); @@ -252,63 +468,34 @@ impl super::OpenCADStudio { Task::none() } - /// POLYSOLID — build a wall-like solid from a selected polyline: one box per - /// segment (oriented along it, `width` wide, `height` tall) unioned together, - /// reusing the box primitive + the boolean union path. + /// POLYSOLID — build a wall solid around an exact polyline offset. pub(super) fn solid_polysolid(&mut self, width: f64, height: f64) -> Task { let i = self.active_tab; - let found: Option<(Handle, Vec<[f64; 3]>)> = self.tabs[i] + let found: Option<(Handle, EntityType)> = self.tabs[i] .scene .selected_entities() .iter() .filter(|(h, _)| !self.tabs[i].scene.is_layer_locked(*h)) .find_map(|(h, e)| match e { - EntityType::LwPolyline(_) => crate::entities::curve::entity_curve(e) - .map(|curve| (*h, crate::entities::curve::curve_points(&curve))), + EntityType::LwPolyline(_) => Some((*h, (*e).clone())), _ => None, }); - let Some((handle, pts)) = found else { + let Some((handle, entity)) = found else { self.command_line .push_error(crate::t!("POLYSOLID: select a polyline first.").as_ref()); return Task::none(); }; - let segs: Vec<([f64; 3], [f64; 3])> = pts.windows(2).map(|w| (w[0], w[1])).collect(); - let mut acc: Option = None; - let mut used = 0usize; - for (a, b) in &segs { - let (dx, dy) = (b[0] - a[0], b[1] - a[1]); - let len = (dx * dx + dy * dy).sqrt(); - if len < 1e-9 { - continue; - } - let angle = dy.atan2(dx); - // Local box: X 0..len, Y -w/2..w/2, Z 0..h, then turned to lie - // along the segment and moved onto its start. - let (sin, cos) = angle.sin_cos(); - let Some(bx) = solid_model::box_solid([len / 2.0, 0.0, height / 2.0], len, width, height) - .and_then(|bx| { - solid_model::placed( - &bx, - [cos, sin, 0.0], - [-sin, cos, 0.0], - [0.0, 0.0, 1.0], - [a[0], a[1], a[2]], - ) - }) - else { - continue; - }; - acc = Some(match acc { - None => bx, - Some(prev) => solid_model::boolean(Bool::Union, &prev, &bx).unwrap_or(prev), - }); - used += 1; - } - let Some(result) = acc else { + let Some(result) = crate::scene::model::sweep_model::polysolid(&entity, width, height) + else { self.command_line - .push_error(crate::t!("POLYSOLID: the polyline has no usable segments.").as_ref()); + .push_error(crate::t!("POLYSOLID: the kernel could not offset the polyline.").as_ref()); return Task::none(); }; + if crate::scene::convert::acis_export::solid_to_sat(&result).is_none() { + self.command_line + .push_error(crate::t!("POLYSOLID: the result could not be encoded as ACIS.").as_ref()); + return Task::none(); + } self.push_undo_snapshot(i, "POLYSOLID"); self.tabs[i].scene.erase_entities(&[handle]); let mut s3d = Solid3D::new(); @@ -319,11 +506,14 @@ impl super::OpenCADStudio { if !h.is_null() { self.tabs[i].scene.select_entity(h, false); } - self.tabs[i].dirty = true; - self.refresh_properties(); - self.command_line.push_output(crate::tf!( - "POLYSOLID: built a wall solid from {used} segment(s) (width {width}, height {height})." - ).as_ref()); + if !h.is_null() { + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + crate::tf!("POLYSOLID: created a wall solid (width {width}, height {height}).") + .as_ref(), + ); + } Task::none() } @@ -333,14 +523,7 @@ impl super::OpenCADStudio { /// loop on the way through; without that the copy lights black. pub(super) fn solid_mirror3d(&mut self, axis: usize) -> Task { let i = self.active_tab; - let handles: Vec = self.tabs[i] - .scene - .selected - .iter() - .copied() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) - .collect(); + let handles = self.selected_solid_handles(); if handles.len() != 1 { self.command_line .push_error(crate::t!("3DMIRROR: select exactly one solid created this session.").as_ref()); @@ -385,14 +568,7 @@ impl super::OpenCADStudio { dst: [[f64; 3]; 3], ) -> Task { let i = self.active_tab; - let handles: Vec = self.tabs[i] - .scene - .selected - .iter() - .copied() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) - .collect(); + let handles = self.selected_solid_handles(); if handles.len() != 1 { self.command_line .push_error(crate::t!("3DALIGN: select exactly one solid created this session.").as_ref()); @@ -453,14 +629,7 @@ impl super::OpenCADStudio { use acadrust::Line; let i = self.active_tab; - let handles: Vec = self.tabs[i] - .scene - .selected - .iter() - .copied() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) - .collect(); + let handles = self.selected_solid_handles(); if handles.len() != 1 { self.command_line .push_error(crate::t!("SECTION: select exactly one solid created this session.").as_ref()); @@ -537,12 +706,12 @@ impl super::OpenCADStudio { self.tabs[i].scene.deselect_all(); if !handle.is_null() { self.tabs[i].scene.select_entity(handle, false); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output(crate::tf!( + "PYRAMID: created a {n}-sided pyramid (radius {radius}, height {height})." + ).as_ref()); } - self.tabs[i].dirty = true; - self.refresh_properties(); - self.command_line.push_output(crate::tf!( - "PYRAMID: created a {n}-sided pyramid (radius {radius}, height {height})." - ).as_ref()); Task::none() } @@ -636,14 +805,7 @@ impl super::OpenCADStudio { use acadrust::types::Vector3; use acadrust::Line; let i = self.active_tab; - let handles: Vec = self.tabs[i] - .scene - .selected - .iter() - .copied() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) - .collect(); + let handles = self.selected_solid_handles(); if handles.is_empty() { self.command_line .push_error(crate::t!("FLATSHOT: select a solid created this session.").as_ref()); @@ -677,14 +839,7 @@ impl super::OpenCADStudio { use acadrust::entities::{Surface, SurfaceKind, Wire as AWire}; use acadrust::types::Vector3; let i = self.active_tab; - let handles: Vec = self.tabs[i] - .scene - .selected - .iter() - .copied() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) - .collect(); + let handles = self.selected_solid_handles(); if handles.is_empty() { self.command_line .push_error(crate::t!("CONVTOSURFACE: select a solid created this session.").as_ref()); diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index ce6d370c..ad519605 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -8,7 +8,7 @@ use acadrust::entities::mtext::AttachmentPoint; use acadrust::entities::mtext_format::{ parse_mtext, MTextColor, MTextDocument, MTextFont, MTextParagraph, MTextParagraphAlignment, - MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType, + MTextScalar, MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType, }; use acadrust::types::Vector3; use acadrust::{EntityType, Handle, MText}; @@ -38,6 +38,15 @@ pub enum ParaAlign { Justify, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParaNumber { + FirstIndent, + LeftIndent, + RightIndent, + SpaceBefore, + SpaceAfter, +} + /// `pick_list`-friendly wrapper for the 9 attachment points. #[derive(Clone, Copy, PartialEq, Eq)] pub struct JustifyChoice(pub AttachmentPoint); @@ -107,6 +116,23 @@ pub struct MTextEditorState { /// it is NOT derived from the typed content, so adding characters wraps /// to the next line instead of stretching the box into one long line. pub rect_width: f64, + pub rect_height: String, + pub annotative: bool, + pub column_type: i16, + pub column_count: String, + pub column_auto_height: bool, + pub column_flow_reversed: bool, + pub column_width: String, + pub column_gutter: String, + pub paragraph_first_indent: String, + pub paragraph_left_indent: String, + pub paragraph_right_indent: String, + pub paragraph_space_before: String, + pub paragraph_space_after: String, + pub find_text: String, + pub replace_text: String, + undo: Vec, + redo: Vec, /// `Some` when editing an existing entity; `None` for a fresh MText. pub editing: Option, /// When true the panel shows the rendered preview; when false the raw @@ -167,6 +193,23 @@ impl MTextEditorState { // Default box ~20 characters wide; overwritten with the entity's // own width when editing an existing MText. rect_width: (height * 20.0).max(1.0), + rect_height: "0".to_string(), + annotative: false, + column_type: 0, + column_count: "2".to_string(), + column_auto_height: false, + column_flow_reversed: false, + column_width: (height * 10.0).to_string(), + column_gutter: (height * 5.0).to_string(), + paragraph_first_indent: "0".to_string(), + paragraph_left_indent: "0".to_string(), + paragraph_right_indent: "0".to_string(), + paragraph_space_before: "0".to_string(), + paragraph_space_after: "0".to_string(), + find_text: String::new(), + replace_text: String::new(), + undo: Vec::new(), + redo: Vec::new(), editing, screen_anchor: iced::Point::new(60.0, 90.0), original: None, @@ -274,8 +317,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::().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::() + .ok() + .filter(|count| *count > 0) + .unwrap_or(1), + ); + mt.column_data.auto_height = self.column_auto_height; + mt.column_data.flow_reversed = self.column_flow_reversed; + if let Ok(width) = self.column_width.trim().parse::() { + if width > 0.0 { + mt.column_data.width = width; + } + } + if let Ok(gutter) = self.column_gutter.trim().parse::() { + if gutter >= 0.0 { + mt.column_data.gutter = gutter; + } + } + if !self.column_auto_height && rectangle_height > 0.0 { + mt.column_data.heights = vec![ + rectangle_height; + mt.column_data.column_count 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, initial: &str, height: f64, + template: Option, ) { if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) { return; @@ -614,20 +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::().ok() else { + return; + }; + let valid = match field { + "height" => number > 0.0, + "oblique" => (-85.0..=85.0).contains(&number), + "width" => number > 0.0, + "tracking" => number.is_finite(), + _ => false, + }; + if !valid { + return; + } + let has_selection = self + .mtext_editor + .as_ref() + .and_then(|editor| editor.sel) + .is_some_and(|(start, end)| start < end); + if let Some(ed) = self.mtext_editor.as_mut() { + if has_selection { + let (start, end) = ed.sel.unwrap(); + ed.record_undo(); + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let end = end.min(cells.len()); + for cell in &mut cells[start.min(end)..end] { + if let Cell::Char(_, props) | Cell::Stack { props, .. } = cell { + match field { + "height" => props.height = Some(MTextScalar::Absolute(number)), + "oblique" => props.oblique_angle = (number != 0.0).then_some(number), + "width" => props.width_factor = (number != 1.0).then_some(number), + "tracking" => props.tracking = (number != 0.0).then_some(number), + _ => {} + } + } + } + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.sel = Some((start, end)); + ed.caret = end; + } else { + match field { + "height" => ed.height = value, + "oblique" => ed.oblique = value, + "width" => ed.width = value, + "tracking" => ed.char_space = value, + _ => {} + } + } + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_apply_paragraph_number(&mut self, field: ParaNumber, value: String) { + let Some(number) = value.trim().replace(',', ".").parse::().ok() else { + return; + }; + if !number.is_finite() { + return; + } + if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); + match field { + ParaNumber::FirstIndent => ed.paragraph_first_indent = value, + ParaNumber::LeftIndent => ed.paragraph_left_indent = value, + ParaNumber::RightIndent => ed.paragraph_right_indent = value, + ParaNumber::SpaceBefore => ed.paragraph_space_before = value, + ParaNumber::SpaceAfter => ed.paragraph_space_after = value, + } + let para0 = doc_para0(&ed.doc); + let cells = doc_to_cells(&ed.doc); + let (start, end) = ed + .sel + .filter(|(start, end)| start < end) + .unwrap_or((ed.caret, ed.caret)); + let paragraph_of = |index: usize| { + cells + .iter() + .take(index.min(cells.len())) + .filter(|cell| matches!(cell, Cell::Break(..))) + .count() + }; + let first = paragraph_of(start); + let last = paragraph_of(end.saturating_sub((end > start) as usize)); + let mut doc = cells_to_doc(¶0, &cells); + for paragraph in doc + .paragraphs + .iter_mut() + .skip(first) + .take(last.saturating_sub(first) + 1) + { + let optional = (number != 0.0).then_some(number); + match field { + ParaNumber::FirstIndent => paragraph.properties.first_line_indent = optional, + ParaNumber::LeftIndent => paragraph.properties.left_margin = optional, + ParaNumber::RightIndent => paragraph.properties.right_margin = optional, + ParaNumber::SpaceBefore => paragraph.properties.spacing_before = optional, + ParaNumber::SpaceAfter => paragraph.properties.spacing_after = optional, + } + } + ed.content = text_editor::Content::with_text(&doc.to_mtext_string()); + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_stack_selection(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let Some((start, end)) = ed.sel.filter(|(start, end)| start < end) else { + return; + }; + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let end = end.min(cells.len()); + let plain = cells_to_doc(&ParagraphProperties::default(), &cells[start..end]) + .to_plain_text(); + let Some((split, separator)) = plain + .char_indices() + .find(|(_, ch)| matches!(ch, '/' | '#' | '^')) + else { + return; + }; + let separator_len = separator.len_utf8(); + let data = StackingData { + numerator: plain[..split].to_string(), + denominator: plain[split + separator_len..].to_string(), + stacking_type: StackingType::from_char(separator), + }; + if data.numerator.is_empty() || data.denominator.is_empty() { + return; + } + let props = cells[start..end] + .iter() + .find_map(|cell| match cell { + Cell::Char(_, props) | Cell::Stack { props, .. } => Some(props.clone()), + Cell::Break(..) => None, + }) + .unwrap_or_default(); + ed.record_undo(); + let replacement = flatten_stack(&data) + .chars() + .enumerate() + .map(|(index, _)| Cell::Stack { + data: data.clone(), + props: props.clone(), + head: index == 0, + }) + .collect::>(); + let replacement_len = replacement.len(); + cells.splice(start..end, replacement); + let new_end = start + replacement_len; + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.sel = Some((start, new_end)); + ed.caret = new_end; + ed.sel_anchor = start; + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_clear_formatting(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let (start, end) = ed + .sel + .filter(|(start, end)| start < end) + .unwrap_or((0, cells.len())); + let end = end.min(cells.len()); + ed.record_undo(); + for cell in &mut cells[start.min(end)..end] { + if let Cell::Char(_, props) | Cell::Stack { props, .. } = cell { + *props = SpanProperties::default(); + } + } + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.sel = Some((start, end)); + ed.caret = end; + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_find_next(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let needle: Vec = ed.find_text.chars().collect(); + if needle.is_empty() { + return; + } + let cells = doc_to_cells(&ed.doc); + let chars: Vec> = cells + .iter() + .map(|cell| match cell { + Cell::Char(ch, _) => Some(*ch), + Cell::Break(..) => Some('\n'), + Cell::Stack { .. } => None, + }) + .collect(); + 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 = ed.find_text.chars().collect(); + if needle.is_empty() { + return; + } + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let replacement = ed.replace_text.clone(); + let mut matches = Vec::new(); + let mut index = 0usize; + while index + needle.len() <= cells.len() { + let found = cells[index..index + needle.len()] + .iter() + .zip(&needle) + .all(|(cell, expected)| matches!(cell, Cell::Char(actual, _) if actual == expected)); + if found { + matches.push(index); + index += needle.len(); + } else { + index += 1; + } + } + if matches.is_empty() { + return; + } + ed.record_undo(); + for start in matches.into_iter().rev() { + let props = insert_props(&cells, start); + let paragraph = para_props_at(¶0, &cells, start); + let replacement_cells = str_to_cells(&replacement, &props, ¶graph); + cells.splice(start..start + needle.len(), replacement_cells); + } + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.caret = cells.len(); + ed.sel = Some((ed.caret, ed.caret)); + ed.sel_anchor = ed.caret; + } + self.rebuild_mtext_preview(); + } + /// Toggle a character format over the preview selection, on the structured /// span properties. The stroke-font renderer has no true bold, so Bold /// switches the run to the heavier "Gothic" face; Italic applies a 15° @@ -798,6 +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(); @@ -1209,9 +1636,16 @@ impl super::OpenCADStudio { pub(super) fn mtext_commit(&mut self) -> bool { let i = self.active_tab; let Some(ed) = self.mtext_editor.take() else { return false }; + if self.command_mtext_input { + self.pending_command_editor_text = Some(ed.build_mtext().value); + self.command_mtext_input = false; + self.refresh_properties(); + return true; + } 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, @@ -1234,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; @@ -1248,6 +1685,31 @@ 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::MText(_) | EntityType::MultiLeader(_)) + ) { + self.tabs[i] + .scene + .sync_displayed_annotation_context(h); + } self.tabs[i] .scene .bump_entities(&[(h, crate::scene::ChangeKind::Modified)]); @@ -1268,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 { @@ -1293,6 +1754,10 @@ impl super::OpenCADStudio { /// then updated in place on later applies, so repeated Apply never leaves /// duplicate entities behind. pub(super) fn mtext_apply(&mut self) { + if self.command_mtext_input { + self.rebuild_mtext_preview(); + return; + } let i = self.active_tab; let (mut mt, editing, body_empty) = match self.mtext_editor.as_ref() { Some(ed) => ( @@ -1302,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, @@ -1322,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; @@ -1336,6 +1805,30 @@ 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::MText(_) | EntityType::MultiLeader(_)) + ) { + self.tabs[i] + .scene + .sync_displayed_annotation_context(h); + } self.tabs[i] .scene .bump_entities(&[(h, crate::scene::ChangeKind::Modified)]); @@ -1356,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; @@ -1373,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(); @@ -1381,6 +1878,8 @@ impl super::OpenCADStudio { /// Discard the editor without changing the drawing. pub(super) fn mtext_cancel(&mut self) { self.mtext_editor = None; + self.command_mtext_input = false; + self.pending_command_editor_text = None; self.reset_modal_geometry(); } } diff --git a/src/app/properties.rs b/src/app/properties.rs index abb9b0bc..006877cc 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -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}; @@ -128,6 +129,9 @@ impl OpenCADStudio { crate::entities::spline::control_vertex_count(spline) }) } + acadrust::EntityType::Table(table) => { + Some(table.row_count().saturating_mul(table.column_count())) + } _ => None, }); vertex_count.map_or(prop_vertex, |count| prop_vertex.min(count.saturating_sub(1))) @@ -140,6 +144,8 @@ impl OpenCADStudio { false }; crate::scene::view::dispatch::set_prop_current_vertex(prop_vertex); + crate::entities::table::set_prop_current_cell(prop_vertex); + crate::entities::table::set_prop_current_cell_active(prop_vertex_indicator_active); let annotation_scale_handle = self.tabs[i].scene.displayed_annotation_scale_handle(); let new_panel = { @@ -198,11 +204,15 @@ impl OpenCADStudio { }) .unwrap_or_else(|| "ByLayer".to_string()) }; - let plot_style = match header.current_plotstyle_type { - 1 => "ByBlock", - 2 => "ByColor", - 3 => "ByObject", - _ => "ByLayer", + let plot_style = if header.plotstyle_mode { + "ByColor" + } else { + match header.current_plotstyle_type { + 1 => "ByBlock", + 2 => "Normal", + 3 => "ByObject", + _ => "ByLayer", + } }; let layout_plot_table = doc.objects.values().find_map(|object| { let acadrust::objects::ObjectType::Layout(layout) = object else { @@ -273,18 +283,60 @@ impl OpenCADStudio { ), ), }, - read_only(t!("Transparency").as_ref(), "ByLayer".to_string()), + Property { + label: t!("Transparency").into_owned(), + field: "transparency", + value: PropValue::EditChoice { + value: "ByLayer".to_string(), + options: vec!["ByLayer".to_string(), "ByBlock".to_string()], + }, + }, read_only(t!("Thickness").as_ref(), format_length(header.thickness)), ], }, PropSection { title: t!("3D Visualization").into_owned(), - props: vec![read_only(t!("Material").as_ref(), material)], + props: vec![Property { + label: t!("Material").into_owned(), + field: "material", + value: PropValue::Choice { + selected: material.clone(), + options: { + let mut opts = vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Global".to_string(), + ]; + if !opts.contains(&material) && !material.is_empty() { + opts.push(material.clone()); + } + opts + }, + }, + }], }, PropSection { title: t!("Plot style").into_owned(), props: vec![ - read_only(t!("Plot style").as_ref(), plot_style.to_string()), + Property { + label: t!("Plot style").into_owned(), + field: "plot_style", + value: if !header.plotstyle_mode { + PropValue::Choice { + selected: plot_style.to_string(), + options: vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Normal".to_string(), + ], + } + } else { + PropValue::ReadOnlyWithTooltip { + value: t!("ByColor").into_owned(), + tooltip: t!("Plot style is locked to color in Color-Dependent (CTB) mode").into_owned(), + } + }, + }, read_only(t!("Plot style table").as_ref(), plot_table.clone()), read_only( t!("Plot table attached to").as_ref(), @@ -297,9 +349,9 @@ impl OpenCADStudio { read_only( t!("Plot table type").as_ref(), if header.plotstyle_mode { - "Named plot styles".to_string() + t!("Color-dependent plot styles").into_owned() } else { - "Color-dependent plot styles".to_string() + t!("Named plot styles").into_owned() }, ), ], @@ -338,6 +390,23 @@ impl OpenCADStudio { ]; ui::PropertiesPanel { title: t!("No selection").into_owned(), + choice_combos: sections + .iter() + .flat_map(|section| section.props.iter()) + .filter_map(|prop| match &prop.value { + crate::scene::model::object::PropValue::Choice { options, .. } => Some(( + prop.field.to_string(), + iced::widget::combo_box::State::new( + options + .iter() + .cloned() + .map(ui::properties::LocalizedChoice::new) + .collect(), + ), + )), + _ => None, + }) + .collect(), sections, layer_combo: iced::widget::combo_box::State::new(layer_names.clone()), linetype_combo: iced::widget::combo_box::State::new( @@ -381,17 +450,10 @@ impl OpenCADStudio { { let doc = &self.tabs[i].scene.document; let common = entity.common(); - let mat_names: Vec = doc - .objects - .iter() - .filter_map(|(_, o)| match o { - acadrust::objects::ObjectType::Material(m) => Some(m.name.clone()), - _ => None, - }) - .collect(); let selected = match common.material_flags { 0 => "ByLayer".to_string(), 1 => "ByBlock".to_string(), + 2 => "Global".to_string(), _ => common .material_handle .and_then(|mh| { @@ -402,10 +464,16 @@ impl OpenCADStudio { _ => None, }) }) - .unwrap_or_else(|| "ByLayer".to_string()), + .unwrap_or_else(|| "Global".to_string()), }; - let mut options = vec!["ByLayer".to_string(), "ByBlock".to_string()]; - options.extend(mat_names); + let mut options = vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Global".to_string(), + ]; + if !options.contains(&selected) && !selected.is_empty() { + options.push(selected.clone()); + } for section in sections.iter_mut() { if let Some(row) = section.props.iter_mut().find(|p| p.field == "material") @@ -613,6 +681,31 @@ impl OpenCADStudio { entity, )); + { + use crate::entities::common::ro_prop; + let xd = &source_entity.common().extended_data; + if !xd.is_empty() { + let mut xd_props = Vec::new(); + for rec in xd.records() { + let value_text = rec + .values + .iter() + .map(|value| format!("{value:?}")) + .collect::>() + .join(", "); + xd_props.push(ro_prop( + t!("Application").as_ref(), + "xdata_value", + format!("{}: {value_text}", rec.application_name), + )); + } + sections.push(crate::scene::model::object::PropSection { + title: t!("Extended Data").into_owned(), + props: xd_props, + }); + } + } + // Uniform-scale checkbox for block references (#427): // while the three scale factors are equal (and the user // hasn't opted into per-axis editing) the panel shows one @@ -658,39 +751,57 @@ impl OpenCADStudio { } } - // In named plot-style mode (PSTYLEMODE=1) the Plot style row - // picks a named style from the drawing's plot style table. In - // the color-dependent mode it stays read-only (the object's - // color drives the plot style), so it is left untouched. - if self.tabs[i].scene.document.header.plotstyle_mode { + if !self.tabs[i].scene.document.header.plotstyle_mode { let doc = &self.tabs[i].scene.document; let dict_h = doc.header.acad_plotstylename_dict_handle; + let common = entity.common(); + let mut options = vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Normal".to_string(), + ]; if let Some(dict) = crate::scene::annotative::as_dict(doc, dict_h) { - let common = entity.common(); - let mut options = vec!["ByLayer".to_string(), "ByBlock".to_string()]; - options.extend(dict.entries.iter().map(|(n, _)| n.clone())); - let selected = match common.plotstyle_flags { - 0 => "ByLayer".to_string(), - 1 => "ByBlock".to_string(), - _ => common - .plotstyle_handle - .and_then(|ph| { + for (n, _) in &dict.entries { + if !options.contains(n) { + options.push(n.clone()); + } + } + } + let selected = match common.plotstyle_flags { + 0 => "ByLayer".to_string(), + 1 => "ByBlock".to_string(), + 2 => "Normal".to_string(), + _ => common + .plotstyle_handle + .and_then(|ph| { + crate::scene::annotative::as_dict(doc, dict_h).and_then(|dict| { dict.entries .iter() .find(|(_, h)| *h == ph) .map(|(n, _)| n.clone()) }) - .unwrap_or_else(|| "ByLayer".to_string()), - }; - for section in sections.iter_mut() { - if let Some(row) = - section.props.iter_mut().find(|p| p.field == "plot_style") - { - row.value = crate::scene::model::object::PropValue::Choice { - selected: selected.clone(), - options: options.clone(), - }; - } + }) + .unwrap_or_else(|| "ByLayer".to_string()), + }; + for section in sections.iter_mut() { + if let Some(row) = + section.props.iter_mut().find(|p| p.field == "plot_style") + { + row.value = crate::scene::model::object::PropValue::Choice { + selected: selected.clone(), + options: options.clone(), + }; + } + } + } else { + for section in sections.iter_mut() { + if let Some(row) = + section.props.iter_mut().find(|p| p.field == "plot_style") + { + row.value = crate::scene::model::object::PropValue::ReadOnlyWithTooltip { + value: t!("ByColor").into_owned(), + tooltip: t!("Plot style is locked to color in Color-Dependent (CTB) mode").into_owned(), + }; } } } @@ -894,6 +1005,21 @@ impl OpenCADStudio { // Inject DimStyle picker + style-derived groups for Dimensions. if let acadrust::EntityType::Dimension(d) = entity { + if let Some(general) = sections.first_mut() { + general.props.retain(|property| property.field != "handle"); + let associative = + crate::scene::dimension_assoc::dimension_is_associative( + &self.tabs[i].scene.document, + d.base().common.handle, + ); + general.props.push(crate::scene::model::object::Property { + label: t!("Associative").into_owned(), + field: "associative", + value: crate::scene::model::object::PropValue::ReadOnly( + if associative { "Yes" } else { "No" }.to_string(), + ), + }); + } let dim_style_names: Vec = self.tabs[i] .scene .document @@ -905,22 +1031,19 @@ impl OpenCADStudio { if !dim_style_names.is_empty() { // Current style is already shown as text in the geom section; // replace/upgrade it to a Choice if we have a list. - if let Some(geom) = sections.last_mut() { - // Replace the style name with a choice. - if let Some(prop) = - geom.props.iter_mut().find(|p| p.field == "style_name") - { - let current = match &prop.value { - crate::scene::model::object::PropValue::PlainText(s) => { - s.clone() - } - _ => String::new(), - }; - prop.value = crate::scene::model::object::PropValue::Choice { - selected: current, - options: dim_style_names, - }; - } + if let Some(prop) = sections + .iter_mut() + .flat_map(|section| section.props.iter_mut()) + .find(|property| property.field == "style_name") + { + let current = match &prop.value { + crate::scene::model::object::PropValue::PlainText(s) => s.clone(), + _ => String::new(), + }; + prop.value = crate::scene::model::object::PropValue::Choice { + selected: current, + options: dim_style_names, + }; } } @@ -934,12 +1057,24 @@ impl OpenCADStudio { && s.name.eq_ignore_ascii_case("Standard")) }) { - sections.extend(crate::entities::dimension::style_sections(style)); + sections.extend(crate::entities::dimension::style_sections( + style, + d, + &self.tabs[i].scene.document, + )); - // The style groups are a read-only mirror, but the - // dim-line colour is an editable per-object override: - // prefer the ACAD_DSTYLE code-176 (ACI) override, else - // the style's DIMCLRD. + if matches!(d, acadrust::entities::Dimension::LargeRadial(_)) { + if let Some(index) = sections + .iter() + .position(|section| section.title == t!("Misc").as_ref()) + { + let misc = sections.remove(index); + sections.push(misc); + } + } + + // Prefer entity-level dimension-variable overrides; + // fall back to the assigned style values. use crate::entities::dim_override as dov; use crate::scene::model::object::PropValue; let dim_c = dov::color(&d.base().common.extended_data, dov::DIMCLRD) @@ -949,6 +1084,20 @@ impl OpenCADStudio { "dim_line_color", PropValue::ColorChoice(dim_c), ); + for (field, code, inherited) in [ + ("dim_ext_line_color", dov::DIMCLRE, style.dimclre), + ("dim_text_color", dov::DIMCLRT, style.dimclrt), + ] { + let color = dov::color(&d.base().common.extended_data, code) + .unwrap_or_else(|| { + acadrust::types::Color::from_index(inherited) + }); + set_row_value( + &mut sections, + field, + PropValue::ColorChoice(color), + ); + } } } @@ -1169,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 = 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. @@ -1207,18 +1433,364 @@ impl OpenCADStudio { } } } + acadrust::EntityType::Table(table) => { + use acadrust::entities::table::{ + CellEdgeFlags, CellStylePropertyFlags, CellValueType, + }; + + let mut names: Vec = doc + .objects + .values() + .filter_map(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) + if !style.name.trim().is_empty() => + { + Some(style.name.clone()) + } + _ => None, + }) + .collect(); + names.sort_by_key(|name| name.to_ascii_lowercase()); + names.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + let selected_style = table + .table_style_handle + .and_then(|handle| doc.objects.get(&handle)) + .and_then(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) => { + Some(style) + } + _ => None, + }); + let selected = selected_style + .map(|style| style.name.clone()) + .unwrap_or_else(|| "Standard".to_string()); + if !names.iter().any(|name| name.eq_ignore_ascii_case(&selected)) { + names.insert(0, selected.clone()); + } + set_row_value( + &mut sections, + "tbl_style_handle", + crate::scene::model::object::PropValue::Choice { + selected, + options: names, + }, + ); + + let title_suppressed = + crate::entities::table::resolved_title_suppressed( + table, + selected_style, + ); + let header_suppressed = + crate::entities::table::resolved_header_suppressed( + table, + selected_style, + ); + let flow_up = crate::entities::table::resolved_flow_up( + table, + selected_style, + ); + let (horizontal_margin, vertical_margin) = + crate::entities::table::resolved_table_margins( + table, + selected_style, + ); + update_row_toggle( + &mut sections, + "tbl_title_suppressed", + title_suppressed, + ); + update_row_toggle( + &mut sections, + "tbl_header_suppressed", + header_suppressed, + ); + update_row_text( + &mut sections, + "tbl_flow_direction", + if flow_up { "Up" } else { "Down" }.to_string(), + ); + update_row_text( + &mut sections, + "tbl_horizontal_margin", + crate::entities::common::format_length(horizontal_margin), + ); + update_row_text( + &mut sections, + "tbl_vertical_margin", + crate::entities::common::format_length(vertical_margin), + ); + + let columns = table.column_count(); + if columns > 0 { + let cell_index = prop_vertex.min( + table.row_count() + .saturating_mul(columns) + .saturating_sub(1), + ); + let row_index = cell_index / columns; + let column_index = cell_index % columns; + if let (Some(row), Some(cell)) = ( + table.rows.get(row_index), + table.cell(row_index, column_index), + ) { + let document_row_style = selected_style.map(|style| { + let kind = match ( + title_suppressed, + header_suppressed, + row_index, + ) { + (false, _, 0) => 0, + (false, false, 1) | (true, false, 0) => 1, + _ => 2, + }; + match kind { + 0 => &style.title_row_style, + 1 => &style.header_row_style, + _ => &style.data_row_style, + } + }); + let local = |property| { + crate::entities::table::style_for_property( + table, + row, + column_index, + cell, + property, + ) + }; + let alignment = local(CellStylePropertyFlags::ALIGNMENT) + .map(|style| style.alignment) + .or_else(|| { + document_row_style.map(|style| style.alignment as i32) + }) + .unwrap_or(5); + let alignment = match alignment { + 1 => "Top Left", + 2 => "Top Center", + 3 => "Top Right", + 4 => "Middle Left", + 6 => "Middle Right", + 7 => "Bottom Left", + 8 => "Bottom Center", + 9 => "Bottom Right", + _ => "Middle Center", + }; + update_row_text( + &mut sections, + "tbl_cell_alignment", + alignment.to_string(), + ); + + let text_style_name = local(CellStylePropertyFlags::TEXT_STYLE) + .map(|style| style.text_style_name.clone()) + .filter(|name| !name.is_empty()) + .or_else(|| { + document_row_style + .map(|style| style.text_style_name.clone()) + .filter(|name| !name.is_empty()) + }) + .unwrap_or_else(|| "Standard".to_string()); + update_row_text( + &mut sections, + "tbl_cell_text_style", + text_style_name, + ); + let text_height = local(CellStylePropertyFlags::TEXT_HEIGHT) + .map(|style| style.text_height) + .or_else(|| { + document_row_style.map(|style| style.text_height) + }) + .unwrap_or(0.18); + update_row_text( + &mut sections, + "tbl_cell_text_height", + crate::entities::common::format_length(text_height), + ); + let content_color = local( + CellStylePropertyFlags::CONTENT_COLOR, + ) + .map(|style| style.content_color) + .or_else(|| { + document_row_style.map(|style| style.text_color) + }) + .unwrap_or(acadrust::types::Color::ByBlock); + update_row_color( + &mut sections, + "tbl_cell_content_color", + content_color, + ); + let background_style = + local(CellStylePropertyFlags::BACKGROUND_COLOR); + let background_color = background_style + .map(|style| style.background_color) + .or_else(|| { + document_row_style.map(|style| style.fill_color) + }) + .unwrap_or(acadrust::types::Color::ByBlock); + update_row_color( + &mut sections, + "tbl_cell_background_color", + background_color, + ); + update_row_toggle( + &mut sections, + "tbl_cell_fill", + background_style + .map(|style| style.fill_enabled) + .or_else(|| { + document_row_style + .map(|style| style.fill_enabled) + }) + .unwrap_or(false), + ); + let format = local(CellStylePropertyFlags::DATA_FORMAT) + .map(|style| style.value_format.clone()) + .or_else(|| { + document_row_style + .map(|style| style.format_string.clone()) + }) + .unwrap_or_default(); + update_row_text( + &mut sections, + "tbl_cell_format", + format, + ); + let data_type = cell + .contents + .first() + .map(|content| content.value.value_type) + .filter(|kind| *kind != CellValueType::Unknown) + .or_else(|| { + local(CellStylePropertyFlags::DATA_TYPE) + .map(|style| { + CellValueType::from( + style.value_data_type.max(0) as u32, + ) + }) + }) + .or_else(|| { + document_row_style.map(|style| { + CellValueType::from(style.data_type.max(0) as u32) + }) + }) + .unwrap_or(CellValueType::String); + let data_type = match data_type { + CellValueType::Long => "Integer", + CellValueType::Double => "Decimal", + CellValueType::Date => "Date", + CellValueType::Point2D => "Point 2D", + CellValueType::Point3D => "Point 3D", + CellValueType::Handle => "Handle", + _ => "Text", + }; + update_row_text( + &mut sections, + "tbl_cell_data_type", + data_type.to_string(), + ); + for (field, property, fallback) in [ + ( + "tbl_cell_margin_left", + CellStylePropertyFlags::MARGIN_LEFT, + horizontal_margin, + ), + ( + "tbl_cell_margin_top", + CellStylePropertyFlags::MARGIN_TOP, + vertical_margin, + ), + ( + "tbl_cell_margin_right", + CellStylePropertyFlags::MARGIN_RIGHT, + horizontal_margin, + ), + ( + "tbl_cell_margin_bottom", + CellStylePropertyFlags::MARGIN_BOTTOM, + vertical_margin, + ), + ] { + let value = local(property) + .map(|style| match field { + "tbl_cell_margin_left" => style.margin_left, + "tbl_cell_margin_top" => style.margin_top, + "tbl_cell_margin_right" => style.margin_right, + _ => style.margin_bottom, + }) + .unwrap_or(fallback); + update_row_text( + &mut sections, + field, + crate::entities::common::format_length(value), + ); + } + + let border_visible = |edge: CellEdgeFlags| { + let local_style = [ + cell.style.as_ref(), + row.style.as_ref(), + table + .columns + .get(column_index) + .and_then(|column| column.style.as_ref()), + table.base_style.as_ref(), + ] + .into_iter() + .flatten() + .find(|style| { + style.applied_border_edges.contains(edge) + }); + if let Some(style) = local_style { + return if edge == CellEdgeFlags::TOP { + !style.top_border.invisible + } else if edge == CellEdgeFlags::RIGHT { + !style.right_border.invisible + } else if edge == CellEdgeFlags::BOTTOM { + !style.bottom_border.invisible + } else { + !style.left_border.invisible + }; + } + document_row_style + .map(|style| { + if edge == CellEdgeFlags::TOP { + !style.top_border.is_invisible + } else if edge == CellEdgeFlags::RIGHT { + !style.right_border.is_invisible + } else if edge == CellEdgeFlags::BOTTOM { + !style.bottom_border.is_invisible + } else { + !style.left_border.is_invisible + } + }) + .unwrap_or(true) + }; + for (field, edge) in [ + ("tbl_cell_border_top", CellEdgeFlags::TOP), + ("tbl_cell_border_right", CellEdgeFlags::RIGHT), + ("tbl_cell_border_bottom", CellEdgeFlags::BOTTOM), + ("tbl_cell_border_left", CellEdgeFlags::LEFT), + ] { + update_row_toggle( + &mut sections, + field, + border_visible(edge), + ); + } + } + } + } _ => {} } // Annotative Yes/No + a conditional "Annotative scale" row. - // Read-only: annotative state comes from the entity's style - // (or its own flag) and the assigned annotation-scale name(s) - // are walked from the entity's extension dictionary — both - // need the document, so they are resolved here. + // Annotative state and assigned scale names need the + // document, so they are resolved here. { // 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(_) @@ -1232,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( @@ -1256,7 +1847,7 @@ impl OpenCADStudio { // (MTEXT via its native flag, single-line TEXT via the // context alone) get an editable toggle: turning it on // synthesizes a real per-scale representation. The - // remaining types are style-driven and stay read-only. + // remaining style-only types stay read-only. if anno_field == "annotative" { match entity { acadrust::EntityType::MText(t) => set_row_value( @@ -1267,9 +1858,17 @@ 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(_) => set_row_value( + | acadrust::EntityType::Hatch(_) + | acadrust::EntityType::Dimension(_) => set_row_value( &mut sections, "annotative", crate::scene::model::object::PropValue::BoolToggle { @@ -1285,22 +1884,95 @@ impl OpenCADStudio { } } if is_anno { - // The applied annotation scale follows the current - // annotation scale (CANNOSCALE / the status-bar - // scale pill), not a per-object stored value. + let memberships = crate::scene::annotative::object_scale_memberships( + doc, + entity.common().handle, + ); + let assigned_scales = if memberships.is_empty() { + doc.header.current_annotation_scale.clone() + } else { + memberships + .into_iter() + .map(|(name, _)| name) + .collect::>() + .join(", ") + }; insert_row_after( &mut sections, anno_field, crate::entities::common::ro_prop( t!("Annotative scale").as_ref(), "annotative_scale", - doc.header.current_annotation_scale.clone(), + assigned_scales, ), ); } } } + // 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() { @@ -1787,6 +2459,51 @@ impl OpenCADStudio { mut entity: acadrust::EntityType, ) -> Option { let i = self.active_tab; + if let acadrust::EntityType::Table(table) = &mut entity { + const PREFIX: &str = "__OPENCAD_LINK_PENDING__"; + if let Some(path) = table.name.strip_prefix(PREFIX).map(str::to_string) { + use acadrust::entities::table::CellStateFlags; + use acadrust::objects::{ + ClassObject, ClassObjectData, DataLink, ObjectType, + }; + let document = &mut self.tabs[i].scene.document; + let link_handle = document.allocate_handle(); + let mut object = ClassObject::new(ClassObjectData::DataLink(DataLink { + data_adapter: "CSV".to_string(), + description: path.clone(), + tooltip: path.clone(), + connection_string: path.clone(), + status_flags: 1, + update_status: "Linked".to_string(), + ..DataLink::default() + })); + object.handle = link_handle; + document + .objects + .insert(link_handle, ObjectType::ClassObject(object)); + let rows = table.row_count() as i32; + let columns = table.column_count() as i32; + for row in &mut table.rows { + for cell in &mut row.cells { + cell.has_linked_data = true; + cell.data_link_handle = Some(link_handle); + cell.data_link_rows = rows; + cell.data_link_columns = columns; + cell.state.insert( + CellStateFlags::LINKED + | CellStateFlags::CONTENT_LOCKED + | CellStateFlags::FORMAT_LOCKED, + ); + } + } + table.name = std::path::Path::new(&path) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("Linked table") + .to_string(); + table.description = path; + } + } let tracks_draw_anchor = self.tabs[i].active_cmd.is_some() && matches!( &entity, @@ -2017,6 +2734,7 @@ fn make_sections_read_only( { let text = match &property.value { PropValue::ReadOnly(value) + | PropValue::ReadOnlyWithTooltip { value, .. } | PropValue::EditText(value) | PropValue::PlainText(value) | PropValue::LayerChoice(value) @@ -2199,20 +2917,36 @@ fn merge_prop_value( options: other_options, .. }, - ) if options == other_options => PropValue::Choice { - selected: VARIES_LABEL.into(), - options: options.clone(), - }, + ) => { + let mut merged_options = options.clone(); + for opt in other_options { + if !merged_options.contains(opt) { + merged_options.push(opt.clone()); + } + } + PropValue::Choice { + selected: VARIES_LABEL.into(), + options: merged_options, + } + } ( PropValue::EditChoice { options, .. }, PropValue::EditChoice { options: other_options, .. }, - ) if options == other_options => PropValue::EditChoice { - value: VARIES_LABEL.into(), - options: options.clone(), - }, + ) => { + let mut merged_options = options.clone(); + for opt in other_options { + if !merged_options.contains(opt) { + merged_options.push(opt.clone()); + } + } + PropValue::EditChoice { + value: VARIES_LABEL.into(), + options: merged_options, + } + } (PropValue::EditText(_), PropValue::EditText(_)) => { PropValue::EditText(VARIES_LABEL.into()) } @@ -2222,6 +2956,19 @@ fn merge_prop_value( (PropValue::ReadOnly(_), PropValue::ReadOnly(_)) => { PropValue::ReadOnly(VARIES_LABEL.into()) } + (PropValue::ReadOnlyWithTooltip { tooltip, .. }, PropValue::ReadOnlyWithTooltip { .. }) => { + PropValue::ReadOnlyWithTooltip { + value: VARIES_LABEL.into(), + tooltip: tooltip.clone(), + } + } + (PropValue::ReadOnly(_), PropValue::ReadOnlyWithTooltip { tooltip, .. }) + | (PropValue::ReadOnlyWithTooltip { tooltip, .. }, PropValue::ReadOnly(_)) => { + PropValue::ReadOnlyWithTooltip { + value: VARIES_LABEL.into(), + tooltip: tooltip.clone(), + } + } (PropValue::HatchPatternChoice(_), PropValue::HatchPatternChoice(_)) => { PropValue::HatchPatternChoice(VARIES_LABEL.into()) } @@ -2266,21 +3013,83 @@ fn set_row_value( } } +fn update_row_text( + sections: &mut [crate::scene::model::object::PropSection], + field: &str, + value: String, +) { + use crate::scene::model::object::PropValue; + for section in sections.iter_mut() { + let Some(row) = section.props.iter_mut().find(|property| property.field == field) else { + continue; + }; + match &mut row.value { + PropValue::ReadOnly(current) + | PropValue::EditText(current) + | PropValue::PlainText(current) => *current = value, + PropValue::ReadOnlyWithTooltip { value: current, .. } => *current = value, + PropValue::Choice { selected, .. } => *selected = value, + _ => {} + } + return; + } +} + +fn update_row_toggle( + sections: &mut [crate::scene::model::object::PropSection], + field: &str, + value: bool, +) { + use crate::scene::model::object::PropValue; + for section in sections.iter_mut() { + let Some(row) = section.props.iter_mut().find(|property| property.field == field) else { + continue; + }; + match &mut row.value { + PropValue::BoolToggle { value: current, .. } => *current = value, + PropValue::ReadOnly(current) => { + *current = if value { t!("Yes") } else { t!("No") }.into_owned() + } + PropValue::ReadOnlyWithTooltip { value: current, .. } => { + *current = if value { t!("Yes") } else { t!("No") }.into_owned() + } + _ => {} + } + return; + } +} + +fn update_row_color( + sections: &mut [crate::scene::model::object::PropSection], + field: &str, + color: acadrust::types::Color, +) { + use crate::scene::model::object::PropValue; + let label = match color { + acadrust::types::Color::None => "None".to_string(), + acadrust::types::Color::ByLayer => "ByLayer".to_string(), + acadrust::types::Color::ByBlock => "ByBlock".to_string(), + acadrust::types::Color::Index(index) => index.to_string(), + acadrust::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"), + }; + for section in sections.iter_mut() { + let Some(row) = section.props.iter_mut().find(|property| property.field == field) else { + continue; + }; + match &mut row.value { + PropValue::ColorChoice(current) => *current = color, + PropValue::ReadOnly(current) => *current = label, + PropValue::ReadOnlyWithTooltip { value: current, .. } => *current = label, + _ => {} + } + return; + } +} + /// The lineweight dropdown options (named defaults + the standard millimetre /// steps), matching the labels `dim_lineweight_label` produces. pub(crate) fn lineweight_options() -> Vec { - let mut v = vec![ - "ByLayer".to_string(), - "ByBlock".to_string(), - "Default".to_string(), - ]; - for lw in [ - 0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, - 200, 211, - ] { - v.push(format!("{:.2} mm", lw as f64 / 100.0)); - } - v + crate::entities::common::lineweight_options() } /// Inverse of `dim_lineweight_label`: a lineweight label → DIMLWD enum value. @@ -2406,13 +3215,7 @@ fn leader_arrow_label( /// DIMLWD lineweight enum → label. fn dim_lineweight_label(dimlwd: i16) -> String { - match dimlwd { - -1 => "ByLayer".to_string(), - -2 => "ByBlock".to_string(), - -3 => "Default".to_string(), - v if v >= 0 => format!("{:.2} mm", v as f64 / 100.0), - _ => "Default".to_string(), - } + crate::entities::common::lineweight_label(dimlwd) } /// Human-readable INSUNITS name (DXF group 70 unit codes). diff --git a/src/app/settings.rs b/src/app/settings.rs index 4e4ad329..8b65c73e 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -230,6 +230,33 @@ pub struct UserSettings { /// Interface language preference. `System` negotiates against the /// platform locale on every launch. pub language: crate::i18n::Language, + /// CLIPROMPTLINES: how many temporary prompt lines for a single command + /// are displayed above the command window (0–50, Registry, default 3). + #[serde(default = "default_clipromptlines", deserialize_with = "deserialize_clipromptlines")] + pub cliprompt_lines: i32, + /// Most-recently-inserted block names, most recent first, capped to 20. + /// Used to rank INSERT suggestions without touching the drawing file. + #[serde(default)] + pub block_mru: Vec, + /// Insertion frequency per block name (uppercase key → count). + #[serde(default)] + pub block_freq: std::collections::HashMap, +} + +fn default_clipromptlines() -> i32 { + 3 +} + +fn deserialize_clipromptlines<'de, D>(de: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let v = i32::deserialize(de).unwrap_or(3); + Ok(v.clamp(0, 50)) +} + +pub fn clamp_clipromptlines(v: i32) -> i32 { + v.clamp(0, 50) } impl Default for UserSettings { @@ -268,6 +295,9 @@ impl Default for UserSettings { bg_color: None, paper_bg_color: None, language: crate::i18n::Language::default(), + cliprompt_lines: 3, + block_mru: Vec::new(), + block_freq: std::collections::HashMap::new(), } } } diff --git a/src/app/style_ops.rs b/src/app/style_ops.rs index 0bdad07b..e2ec1a2f 100644 --- a/src/app/style_ops.rs +++ b/src/app/style_ops.rs @@ -18,16 +18,134 @@ //! style added without a handle (dropped on DWG save, issue #67). use super::OpenCADStudio; -use acadrust::objects::{MLineStyle, MultiLeaderStyle, ObjectType, TableStyle}; +use acadrust::objects::{ + Dictionary, MLineStyle, MultiLeaderStyle, ObjectType, TableStyle, +}; use acadrust::tables::{DimStyle, TextStyle}; use acadrust::types::Handle; +const MLEADERSTYLE_DICT_NAME: &str = "ACAD_MLEADERSTYLE"; + +fn mleaderstyle_dict_handle(doc: &acadrust::CadDocument) -> Option { + let root_h = doc.header.named_objects_dict_handle; + + let root = match doc.objects.get(&root_h) { + Some(ObjectType::Dictionary(root)) => root, + _ => return None, + }; + + root.entries + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(MLEADERSTYLE_DICT_NAME)) + .map(|(_, handle)| *handle) + .filter(|handle| { + matches!( + doc.objects.get(handle), + Some(ObjectType::Dictionary(_)) + ) + }) +} + +fn import_mleaderstyle_names_from_dictionary(doc: &mut acadrust::CadDocument) { + let Some(dict_h) = mleaderstyle_dict_handle(doc) else { + return; + }; + + let entries = match doc.objects.get(&dict_h) { + Some(ObjectType::Dictionary(dict)) => dict.entries.clone(), + _ => return, + }; + + for (name, handle) in entries { + if let Some(ObjectType::MultiLeaderStyle(style)) = + doc.objects.get_mut(&handle) + { + style.name = name; + style.owner_handle = dict_h; + } + } +} + +fn sync_mleaderstyle_dictionary(doc: &mut acadrust::CadDocument) { + let root_h = crate::scene::annotative::root_named_dict_handle(doc); + + let existing = match doc.objects.get(&root_h) { + Some(ObjectType::Dictionary(root)) => root + .entries + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(MLEADERSTYLE_DICT_NAME)) + .map(|(_, handle)| *handle), + _ => None, + }; + + let dict_h = match existing.filter(|handle| { + matches!( + doc.objects.get(handle), + Some(ObjectType::Dictionary(_)) + ) + }) { + Some(handle) => handle, + None => { + let handle = doc.allocate_handle(); + + let mut dict = Dictionary::new(); + dict.handle = handle; + dict.owner = root_h; + + doc.objects + .insert(handle, ObjectType::Dictionary(dict)); + + handle + } + }; + + if let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&root_h) { + root.entries + .retain(|(name, _)| !name.eq_ignore_ascii_case(MLEADERSTYLE_DICT_NAME)); + + root.add_entry(MLEADERSTYLE_DICT_NAME, dict_h); + } + + let mut entries: Vec<(String, Handle)> = doc + .objects + .iter() + .filter_map(|(&handle, object)| match object { + ObjectType::MultiLeaderStyle(style) => { + Some((style.name.clone(), handle)) + } + _ => None, + }) + .collect(); + + entries.sort_by(|a, b| { + a.0.to_lowercase().cmp(&b.0.to_lowercase()) + }); + + for (_, handle) in &entries { + if let Some(ObjectType::MultiLeaderStyle(style)) = + doc.objects.get_mut(handle) + { + style.owner_handle = dict_h; + } + } + + if let Some(ObjectType::Dictionary(dict)) = doc.objects.get_mut(&dict_h) { + dict.owner = root_h; + dict.entries = entries; + + // MLEADERSTYLE uses soft-owner entries (group 350). + dict.hard_owner = false; + dict.hard_owner_entries.clear(); + } +} + /// Guarantee the built-in "Standard" style of every kind exists in `doc` — /// a foreign or damaged file saved without them leaves the style dropdowns /// broken with no way to recover, and new text/dimensions have nothing to /// reference (#366). Missing entries are re-seeded with the app defaults. /// Called on every file open; a no-op for healthy documents. pub(crate) fn ensure_standard_styles(doc: &mut acadrust::CadDocument) { + import_mleaderstyle_names_from_dictionary(doc); if !doc .text_styles .iter() @@ -68,6 +186,8 @@ pub(crate) fn ensure_standard_styles(doc: &mut acadrust::CadDocument) { s.handle = doc.allocate_handle(); doc.objects.insert(s.handle, ObjectType::MultiLeaderStyle(s)); } + sync_mleaderstyle_dictionary(doc); + if !has(doc, |o| match o { ObjectType::MLineStyle(s) => Some(&s.name), _ => None, @@ -741,6 +861,7 @@ impl OpenCADStudio { for (h, o) in &snap.style_objects { doc.objects.insert(*h, o.clone()); } + sync_mleaderstyle_dictionary(doc); doc.header.current_text_style_name = snap.current_text.clone(); doc.header.current_dimstyle_name = snap.current_dim.clone(); doc.header.multiline_style = snap.multiline_style.clone(); @@ -772,11 +893,77 @@ impl OpenCADStudio { self.sync_ribbon_styles(); return; }; + sync_mleaderstyle_dictionary( + &mut self.tabs[i].scene.document, + ); + let edited = self.capture_style_state(); let changed = edited != stage.baseline; if changed { self.tabs[i].dirty = true; let (text_names, dim_names, object_handles) = edited.changed_keys(&stage.baseline); + let changed_mleader_styles: + Vec = + object_handles + .iter() + .filter_map(|handle| { + match self.tabs[i] + .scene + .document + .objects + .get(handle) + { + Some( + acadrust::objects::ObjectType:: + MultiLeaderStyle(style), + ) => Some(style.clone()), + _ => None, + } + }) + .collect(); + + let mut changed_mleaders = Vec::new(); + + for style in changed_mleader_styles { + let entity_handles: Vec = { + let doc = &self.tabs[i].scene.document; + + doc.entities() + .filter_map(|entity| { + match entity { + acadrust::EntityType::MultiLeader(ml) + if ml.style_handle + == Some(style.handle) => + { + Some(ml.common.handle) + } + _ => None, + } + }) + .collect() + }; + + for handle in entity_handles { + if crate::scene::annotative:: + apply_mleader_style_to_object( + &mut self.tabs[i].scene.document, + handle, + &style, + ) + { + changed_mleaders.push(( + handle, + crate::scene::ChangeKind::Modified, + )); + } + } + } + + if !changed_mleaders.is_empty() { + self.tabs[i] + .scene + .bump_entities(&changed_mleaders); + } self.tabs[i].scene.invalidate_text_style_dependencies_many(&text_names); self.tabs[i] .scene diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs index c1f8b495..e505d908 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -95,6 +95,9 @@ pub struct TextInlineState { pub editing: Option, /// Which entity slot this session writes to on commit. pub field: TextEntityField, + /// Fully prepared entity supplied by the interactive TEXT command. Editing + /// existing text and legacy direct-open paths leave this empty. + pub creation: Option, /// Canvas-space anchor where the field is drawn (the insertion-point click). pub screen_anchor: iced::Point, } @@ -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, ) { 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(); diff --git a/src/app/tolerance_dialog.rs b/src/app/tolerance_dialog.rs new file mode 100644 index 00000000..f5ea73d2 --- /dev/null +++ b/src/app/tolerance_dialog.rs @@ -0,0 +1,108 @@ +use acadrust::{EntityType, Handle}; + +impl super::OpenCADStudio { + pub(super) fn open_tolerance_dialog(&mut self, editing: Option) { + 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 + } +} diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 805c8959..fc21b8e7 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -202,6 +202,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // Command-line entry is shown uppercase. self.command_line.input.push_str(&s.to_uppercase()); self.command_line.cancel_history_navigation(); + // Live incremental search for INSERT/MINSERT (see CommandInput) + let live = self.command_line.input.clone(); + let i = self.active_tab; + let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if cmd.on_live_input(&live) { (true, cmd.options(), cmd.prompt()) } else { (false, Vec::new(), String::new()) } + } else { (false, Vec::new(), String::new()) }; + if should_update { + self.command_line.set_step_options(opts); + if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } } + } } } self.command_line.autocomplete_cursor = None; @@ -231,6 +241,18 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.command_line.input.pop(); self.command_line.autocomplete_cursor = None; self.command_line.cancel_history_navigation(); + // Live incremental search for INSERT/MINSERT + { + let live = self.command_line.input.clone(); + let i = self.active_tab; + let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if cmd.on_live_input(&live) { (true, cmd.options(), cmd.prompt()) } else { (false, Vec::new(), String::new()) } + } else { (false, Vec::new(), String::new()) }; + if should_update { + self.command_line.set_step_options(opts); + if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } } + } + } self.focus_cmd_input() } @@ -1140,9 +1162,36 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if matches!( item.action, GripMenuAction::Stretch + | GripMenuAction::MoveWithText + | GripMenuAction::MoveWithDimLine | GripMenuAction::MoveWithLeader | GripMenuAction::MoveIndependent ) { + let is_dimension = matches!( + self.tabs[i].scene.document.get_entity(popup.handle), + Some(acadrust::EntityType::Dimension(_)) + ); + if is_dimension { + let movement = match item.action { + GripMenuAction::MoveWithDimLine => Some("Keep dim line with text"), + GripMenuAction::MoveWithLeader => Some("Move text, add leader"), + GripMenuAction::MoveIndependent => Some("Move text, no leader"), + _ => None, + }; + if let Some(movement) = movement { + crate::entities::dim_override::set_property( + &mut self.tabs[i].scene.document, + popup.handle, + "dim_text_movement", + movement, + ); + self.tabs[i].dirty = true; + self.tabs[i].scene.bump_entities(&[( + popup.handle, + crate::scene::ChangeKind::Modified, + )]); + } + } // Stretch / Move = grab this grip. Engage it so the next // click places it (click-move-click) — same as picking the // grip directly in the viewport. Without this the menu just @@ -1153,13 +1202,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .zip(self.tabs[i].selected_grips.iter()) .find(|(owner, g)| **owner == popup.handle && g.id == popup.grip_id) { - // "Move with Leader" drags the whole multileader; the - // others move just the picked grip. + // Only multileaders use the whole-object grip. + let is_multileader = matches!( + self.tabs[i].scene.document.get_entity(popup.handle), + Some(acadrust::EntityType::MultiLeader(_)) + ); let (grip_id, is_translate) = - if matches!(item.action, GripMenuAction::MoveWithLeader) { + if matches!(item.action, GripMenuAction::MoveWithLeader) + && is_multileader + { (crate::entities::multileader::MOVE_ALL_GRIP, true) } else { - (popup.grip_id, 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, @@ -1672,9 +1730,18 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } StyleKey::MLeaderStyle => { - self.ribbon.active_mleader_style = name.clone(); let i = self.active_tab; - self.tabs[i].active_mleader_style = name; + + self.ribbon.active_mleader_style = name.clone(); + self.tabs[i].active_mleader_style = name.clone(); + + self.tabs[i] + .scene + .document + .header + .current_mleader_style_name = name; + + self.tabs[i].dirty = true; } StyleKey::TableStyle => { self.ribbon.active_table_style = name; @@ -1839,7 +1906,90 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } self.push_undo_snapshot(i, "CHPROP"); - if field == "vscale_std" { + 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; + } + if matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Dimension(_)) + ) { + crate::entities::dim_override::set_property( + &mut self.tabs[i].scene.document, + handle, + field, + &value, + ); + } + } + } else if field == "vscale_std" { for &handle in &handles { if matches!( self.tabs[i].scene.document.get_entity(handle), @@ -2109,7 +2259,43 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].scene.bump_entities(&changes); } } + } else if field == "tbl_style_handle" { + let style_handle = self.tabs[i] + .scene + .document + .objects + .iter() + .find_map(|(handle, object)| match object { + acadrust::objects::ObjectType::TableStyle(style) + if style.name.eq_ignore_ascii_case(value.trim()) => + { + Some(*handle) + } + _ => None, + }); + if let Some(style_handle) = style_handle { + for &handle in &handles { + if let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + table.table_style_handle = Some(style_handle); + } + } + } + } else if field == "transparency" { + for &handle in &handles { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_common_prop(entity, "transparency", &value); + } + } } else if field == "plot_style" { + if self.tabs[i].scene.document.header.plotstyle_mode { + self.refresh_properties(); + return Task::none(); + } // Named plot-style pick: ByLayer / ByBlock clear the // handle; a named style resolves through the drawing's // ACAD_PLOTSTYLENAME dictionary to its placeholder handle. @@ -2139,6 +2325,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { common.plotstyle_flags = 1; common.plotstyle_handle = None; } + "Normal" => { + common.plotstyle_flags = 2; + common.plotstyle_handle = None; + } _ => { if let Some(h) = ph { common.plotstyle_flags = 3; @@ -2179,6 +2369,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { common.material_flags = 1; common.material_handle = None; } + "Global" => { + common.material_flags = 2; + common.material_handle = None; + } _ => { if let Some(h) = mat_handle { common.material_flags = 3; @@ -2231,13 +2425,75 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } + if field.starts_with("tbl_") { + for &handle in &handles { + if let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + table.block_record_handle = None; + } + } + } self.invalidate_property_targets(i, &handles); self.tabs[i].dirty = true; + self.tabs[i].properties.edit_choice_open = false; if field == "spline_method" { self.tabs[i].properties.prop_vertex = 0; self.tabs[i].properties.prop_vertex_indicator_active = false; } self.refresh_properties(); + } else { + match field { + "transparency" => { + self.tabs[i].dirty = true; + self.refresh_properties(); + } + "material" => { + let mat_handle: Option = self.tabs[i] + .scene + .document + .objects + .iter() + .find_map(|(h, o)| match o { + acadrust::objects::ObjectType::Material(m) if m.name == value => { + Some(*h) + } + _ => None, + }); + match value.as_str() { + "ByLayer" | "ByBlock" | "Global" => { + self.tabs[i].scene.document.header.current_material_handle = + acadrust::Handle::NULL; + } + _ => { + if let Some(h) = mat_handle { + self.tabs[i].scene.document.header.current_material_handle = + h; + } + } + } + self.tabs[i].dirty = true; + self.refresh_properties(); + } + "plot_style" => { + if self.tabs[i].scene.document.header.plotstyle_mode { + self.refresh_properties(); + return Task::none(); + } + match value.as_str() { + "ByBlock" => { + self.tabs[i].scene.document.header.current_plotstyle_type = 1 + } + "Normal" => { + self.tabs[i].scene.document.header.current_plotstyle_type = 2 + } + _ => self.tabs[i].scene.document.header.current_plotstyle_type = 0, + } + self.tabs[i].dirty = true; + self.refresh_properties(); + } + _ => {} + } } Task::none() } @@ -2298,7 +2554,30 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { return Task::none(); } self.push_undo_snapshot(i, "CHPROP"); - if field == "block" { + if field == "tbl_style_handle" { + let style_handle = self.tabs[i] + .scene + .document + .objects + .iter() + .find_map(|(handle, object)| match object { + acadrust::objects::ObjectType::TableStyle(style) + if style.name.eq_ignore_ascii_case(val.trim()) => + { + Some(*handle) + } + _ => None, + }); + if let Some(style_handle) = style_handle { + for &handle in &handles { + if let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + table.table_style_handle = Some(style_handle); + } + } + } + } else if field == "block" { // Name row on a block reference: an existing name // re-points the selected inserts; a new one renames // the definition they share. Commit closes the list. @@ -2344,6 +2623,40 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { 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::() { + 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), + Some(acadrust::EntityType::Dimension(_)) + ) { + crate::entities::dim_override::set_property( + &mut self.tabs[i].scene.document, + handle, + field, + &val, + ); + } + } "hyperlink" => { // Stored in the standard PE_URL XDATA // record; an empty value clears it. @@ -2466,10 +2779,25 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } + if field.starts_with("tbl_") { + for &handle in &handles { + if let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + table.block_record_handle = None; + } + } + } self.invalidate_property_targets(i, &handles); self.tabs[i].dirty = true; self.refresh_properties(); } + } else { + let _ = self.tabs[i] + .properties + .edit_buf + .remove(&crate::ui::properties::FieldKey::Geom(field)); + self.refresh_properties(); } Task::none() } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 62a76667..b630a657 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -120,6 +120,32 @@ type ClippedPlotParams = ( Option<(f32, f32, f32, f32)>, ); +fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) { + use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; + let orientation = if d.orientation == "Portrait" { + Orientation::Portrait + } else { + Orientation::Landscape + }; + let standard = match d.paper.as_str() { + "A3" => Some(PaperSize::A3), + "A2" => Some(PaperSize::A2), + "A1" => Some(PaperSize::A1), + "A0" => Some(PaperSize::A0), + "A4" => Some(PaperSize::A4), + _ => None, + }; + if let Some(paper) = standard { + return sheet_mm(paper, orientation); + } + let short = d.paper_width_mm.min(d.paper_height_mm).max(1.0); + let long = d.paper_width_mm.max(d.paper_height_mm).max(1.0); + match orientation { + Orientation::Portrait => (short, long), + Orientation::Landscape => (long, short), + } +} + fn plot_content_extents( wires: &[crate::io::pdf_export::PlotWire], hatches: &[crate::scene::model::hatch_model::HatchModel], @@ -169,7 +195,25 @@ fn plot_scene_content( crate::io::pdf_export::PlotGroupSplits, ) { let (mut paper_wires, mut model_wires) = scene.plot_wire_groups(render_mode_override); - paper_wires.retain(|wire| wire.plot_visible); + let plot_viewport_borders = scene + .effective_plot_settings() + .is_none_or(|settings| settings.flags.plot_viewport_borders); + paper_wires.retain(|wire| { + wire.plot_visible + && (plot_viewport_borders + || !crate::scene::Scene::handle_from_wire_name(&wire.name) + .and_then(|handle| scene.document.get_entity(handle)) + .is_some_and(|entity| { + matches!( + entity, + acadrust::EntityType::Viewport(viewport) + if !crate::scene::Scene::is_sheet_viewport( + &scene.document, + viewport, + ) + ) + })) + }); model_wires.retain(|wire| wire.plot_visible); let with_depth = |wires: Vec| { let depths = scene.plot_wire_depths(&wires); @@ -237,36 +281,72 @@ fn plot_scene_content( } impl OpenCADStudio { - /// Before a save, give every cached solid that still has no ACIS - /// geometry (EXTRUDE/REVOLVE/SWEEP/LOFT/boolean results) an exact modeler - /// body derived from its B-rep, so the written DWG/DXF carries real - /// 3-D geometry other CAD apps can open instead of an empty data stream. - /// A body holding something the kernel has no ACIS record form for is - /// left untouched rather than written out half-complete. - fn sync_solid_models_to_acis(&mut self, i: usize) { + /// Persist exact ACIS bodies and kernel-derived edge caches before saving. + fn sync_solid_models_for_save(&mut self, i: usize) { use acadrust::EntityType; let scene = &mut self.tabs[i].scene; - let targets: Vec = scene - .solid_models - .keys() - .copied() - .filter(|h| { - matches!( - scene.document.get_entity(*h), - Some(EntityType::Solid3D(s)) if !s.acis_data.has_data() - ) + let targets: Vec<(acadrust::Handle, bool, bool)> = scene + .document + .entities() + .filter_map(|entity| { + let EntityType::Solid3D(solid) = entity else { + return None; + }; + let h = solid.common.handle; + let needs_acis = !solid.acis_data.has_data(); + let needs_wires = solid.wires.is_empty(); + (needs_acis || needs_wires).then_some((h, needs_acis, needs_wires)) }) .collect(); - for h in targets { - // Build the SAT while borrowing solid_models; the returned document - // is owned, so the borrow ends before we mutate the entity. - let sat = scene - .solid_models - .get(&h) - .and_then(crate::scene::convert::acis_export::planar_solid_to_sat); - if let Some(sat) = sat { - if let Some(EntityType::Solid3D(s)) = scene.document.get_entity_mut(h) { - s.set_sat_document(&sat); + for (h, needs_acis, needs_wires) in targets { + let body = scene.solid_models.get(&h); + let sat = needs_acis + .then(|| { + body.and_then(crate::scene::convert::acis_export::solid_to_sat) + }) + .flatten(); + let wires = needs_wires.then(|| { + if let Some(body) = body { + return crate::scene::model::solid_model::edge_wires(body); + } + let Some(mesh) = scene.meshes.get(&h).or_else(|| scene.block_meshes.get(&h)) else { + return Vec::new(); + }; + mesh.edge_verts + .chunks_exact(2) + .enumerate() + .map(|(index, points)| { + let first_low = mesh + .edge_verts_low + .get(index * 2) + .copied() + .unwrap_or([0.0; 3]); + let second_low = mesh + .edge_verts_low + .get(index * 2 + 1) + .copied() + .unwrap_or([0.0; 3]); + acadrust::entities::Wire::from_points(vec![ + acadrust::types::Vector3::new( + points[0][0] as f64 + first_low[0] as f64, + points[0][1] as f64 + first_low[1] as f64, + points[0][2] as f64 + first_low[2] as f64, + ), + acadrust::types::Vector3::new( + points[1][0] as f64 + second_low[0] as f64, + points[1][1] as f64 + second_low[1] as f64, + points[1][2] as f64 + second_low[2] as f64, + ), + ]) + }) + .collect() + }); + if let Some(EntityType::Solid3D(solid)) = scene.document.get_entity_mut(h) { + if let Some(sat) = sat { + solid.set_sat_document(&sat); + } + if let Some(wires) = wires.filter(|wires| !wires.is_empty()) { + solid.wires = wires; } } } @@ -313,6 +393,9 @@ impl OpenCADStudio { bg_color: self.default_bg_color.map(f4_to_u3), paper_bg_color: self.default_paper_bg_color.map(f4_to_u3), language: self.language, + cliprompt_lines: crate::app::settings::clamp_clipromptlines(self.cliprompt_lines), + block_mru: self.block_mru.clone(), + block_freq: self.block_freq.clone(), } } @@ -371,6 +454,17 @@ impl OpenCADStudio { if crate::i18n::set_language(s.language).is_ok() { self.language = s.language; } + self.cliprompt_lines = crate::app::settings::clamp_clipromptlines(s.cliprompt_lines); + self.command_line + .set_cliprompt_lines(self.cliprompt_lines.clamp(0, 50) as u8); + // Block usage: clone but cap to sane sizes (MRU 20, freq map 200) + self.block_mru = s.block_mru.iter().take(20).cloned().collect(); + self.block_freq = s + .block_freq + .iter() + .take(200) + .map(|(k, v)| (k.clone(), *v)) + .collect(); // Push the restored background onto every drawing tab that exists now // (the start tab and any initial drawing). Tabs created later pick it // up via `apply_bg_default` at their construction site. @@ -380,6 +474,79 @@ impl OpenCADStudio { self.rebuild_ribbon_modules(); } + pub(crate) fn record_block_insert(&mut self, name: &str) { + let key = name.to_ascii_uppercase(); + *self.block_freq.entry(key).or_insert(0) += 1; + self.block_mru.retain(|n| !n.eq_ignore_ascii_case(name)); + self.block_mru.insert(0, name.to_string()); + if self.block_mru.len() > 20 { + self.block_mru.truncate(20); + } + // Cap freq map to 200 most frequent to bound persistence size. + if self.block_freq.len() > 200 { + let mut items: Vec<(String, u32)> = self + .block_freq + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); + items.sort_by(|a, b| b.1.cmp(&a.1)); + items.truncate(200); + self.block_freq = items.into_iter().collect(); + } + // Debounce disk writes: at most once per second (2.4) to avoid thrash + // during rapid scripting/batch inserts. Immediate first write ensures + // crash recovery. Wasm has no `std::time::Instant` guarantee, so persist + // immediately there. + #[cfg(not(target_arch = "wasm32"))] + { + let now = std::time::Instant::now(); + let should_persist = match self.block_usage_last_persist { + None => true, + Some(t) => now.duration_since(t).as_secs_f32() >= 1.0, + }; + if should_persist { + self.block_usage_last_persist = Some(now); + self.persist_settings_if_changed(); + } + } + #[cfg(target_arch = "wasm32")] + { + self.persist_settings_if_changed(); + } + } + + pub(crate) fn block_usage_snapshot(&self) -> rustc_hash::FxHashMap { + let mut out = rustc_hash::FxHashMap::default(); + for (idx, name) in self.block_mru.iter().enumerate() { + let up = name.to_ascii_uppercase(); + let freq = self.block_freq.get(&up).copied().unwrap_or(0); + out.insert(up, (freq, idx)); + } + // Ensure every freq entry has at least an entry (for blocks not in MRU) + for (up, freq) in &self.block_freq { + out.entry(up.clone()).or_insert((*freq, usize::MAX)); + } + out + } + + pub(crate) fn ranked_block_names(&self, names: &[String]) -> Vec { + let mut scored: Vec<(&String, u32, usize)> = names + .iter() + .map(|n| { + let up = n.to_ascii_uppercase(); + let f = self.block_freq.get(&up).copied().unwrap_or(0); + let m = self + .block_mru + .iter() + .position(|x| x.eq_ignore_ascii_case(n)) + .unwrap_or(usize::MAX); + (n, f, m) + }) + .collect(); + scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)).then_with(|| a.0.cmp(b.0))); + scored.into_iter().map(|(n, _, _)| n.clone()).collect() + } + /// Adopt the per-drawing sysvars stored in tab `i`'s document header — /// Ortho (`$ORTHOMODE`) and the running object-snap set (`$OSMODE`) — into /// the live app state, so each drawing keeps its own. Called when a drawing @@ -456,6 +623,13 @@ impl OpenCADStudio { self.reset_modal_geometry(); let i = self.active_tab; if let Some(mut cmd) = self.tabs[i].suspended_cmd.take() { + if committed { + if let Some(value) = self.pending_command_editor_text.take() { + cmd.on_editor_text(value); + } + } else { + self.pending_command_editor_text = None; + } let res = cmd.on_editor_closed(committed); self.tabs[i].active_cmd = Some(cmd); self.apply_cmd_result(res) @@ -1144,34 +1318,12 @@ pub(super) fn on_open_file(&mut self) -> Task { self.tabs[i].scene.material_base_dir = path.parent().map(std::path::Path::to_path_buf); self.tabs[i].scene.document = doc; - // DWG stores CLAYER as a layer handle. Resolve that handle back to - // the layer name after opening so the per-tab creation state and - // header name stay in sync. DXF already provides current_layer_name, - // so keep it as a fallback. - let current_layer = { - let doc = &self.tabs[i].scene.document; - - doc.layers - .iter() - .find(|layer| layer.handle == doc.header.current_layer_handle) - .map(|layer| (layer.name.clone(), layer.handle)) - .or_else(|| { - doc.layers - .get(&doc.header.current_layer_name) - .map(|layer| (layer.name.clone(), layer.handle)) - }) - .or_else(|| { - doc.layers - .get("0") - .map(|layer| (layer.name.clone(), layer.handle)) - }) - }; - - if let Some((name, handle)) = current_layer { - self.tabs[i].scene.document.header.current_layer_name = name.clone(); - self.tabs[i].scene.document.header.current_layer_handle = handle; - self.tabs[i].active_layer = name; - } + self.tabs[i].active_layer = self.tabs[i] + .scene + .document + .header + .current_layer_name + .clone(); // A file saved without the built-in Standard styles (foreign // or damaged) gets them re-seeded so nothing dangles (#366). crate::app::style_ops::ensure_standard_styles( @@ -1256,6 +1408,8 @@ pub(super) fn on_open_file(&mut self) -> Task { .nth(1) .unwrap_or_else(|| "Model".to_string()), }; + self.tabs[i].scene.load_current_layout_state(); + self.tabs[i].refresh_active_ucs(); } // Object isolation is session-only. A newly opened drawing must // not inherit the previous tab's filter, and persisted entity @@ -1408,14 +1562,23 @@ pub(super) fn on_open_file(&mut self) -> Task { move |result| Message::ObjImportFinished(tab_id, path, result),) } + fn sync_view_state_for_save(&mut self, i: usize) { + self.sync_vport_display(i); + if self.tabs[i].active_block_edit.is_none() { + self.tabs[i].scene.sync_camera_to_document(); + self.tabs[i].last_synced_camera_gen = + self.tabs[i].scene.camera_generation; + } + } + #[cfg(not(target_arch = "wasm32"))] pub(in crate::app) fn prepare_native_save(&mut self, i: usize) { - self.sync_vport_display(i); + self.sync_view_state_for_save(i); sync_annotation_scale_header(&mut self.tabs[i].scene); self.stamp_header_sysvars(i); self.tabs[i].scene.document.header.user_real1 = self.tabs[i].scene.annotation_scale as f64; - self.sync_solid_models_to_acis(i); + self.sync_solid_models_for_save(i); } #[cfg(not(target_arch = "wasm32"))] @@ -1470,16 +1633,133 @@ pub(super) fn on_open_file(&mut self) -> Task { } #[cfg(target_arch = "wasm32")] - fn stamp_thumbnail(&mut self, i: usize, version: acadrust::DxfVersion) { - let scene = &self.tabs[i].scene; - let preview = crate::io::thumbnail::from_snapshot( - &scene.entity_wires(), - &scene.camera.borrow(), - scene.bg_color, - version >= acadrust::DxfVersion::AC1027, - self.vp_size, - ); - self.tabs[i].scene.document.preview = preview; + pub(super) fn on_thumbnail_capture_frame(&mut self) -> Task { + let Some(pending) = self.pending_web_thumbnail_save.take() else { + return Task::none(); + }; + iced::window::latest() + .then(|window| match window { + Some(window) => iced::window::screenshot(window).map(Some), + None => Task::done(None), + }) + .map(move |screenshot| Message::WebSaveScreenshot { + tab_id: pending.tab_id, + filename: pending.filename.clone(), + ext: pending.ext.clone(), + version: pending.version, + bounds: Some(pending.bounds), + screenshot, + }) + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn on_thumbnail_capture_frame(&mut self) -> Task { + let Some(pending) = self.pending_native_thumbnail_save.take() else { + return Task::none(); + }; + let Some(i) = self.tabs.iter().position(|tab| tab.id == pending.tab_id) else { + self.thumbnail_capture_clean = false; + return Task::none(); + }; + self.queue_native_save( + i, + pending.path, + pending.version, + pending.purpose, + pending.continuation, + pending.set_current_path, + pending.check_external_change, + ) + } + + #[cfg(target_arch = "wasm32")] + pub(super) fn on_web_save_screenshot( + &mut self, + tab_id: u64, + filename: String, + ext: String, + version: acadrust::DxfVersion, + bounds: Option, + screenshot: Option, + ) -> Task { + self.thumbnail_capture_clean = false; + let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else { + return Task::none(); + }; + let preview = screenshot.as_ref().and_then(|screenshot| { + bounds.and_then(|bounds| { + crate::io::thumbnail::from_screenshot( + screenshot, + bounds, + version >= acadrust::DxfVersion::AC1027, + ) + }) + }); + if let Some(preview) = preview { + self.tabs[i].scene.document.preview = Some(preview); + } + + let mut recent_task = Task::none(); + // CMMS embedding (not upstream): when launched from cad-edit.php, save + // POSTs back to the CMMS instead of triggering a browser download. + // Standalone use (no window.CAD_EDITOR_CONFIG) is unaffected. + let mut cmms_save_task = Task::none(); + let saved = match crate::io::save_to_bytes( + &self.tabs[i].scene.document, + &ext, + version, + ) { + Ok(bytes) => { + if crate::sys::cmms_config().is_some() { + let post_bytes = bytes.clone(); + let post_filename = filename.clone(); + cmms_save_task = Task::perform( + async move { crate::sys::post_bytes_to_host(&post_bytes, &post_filename).await }, + Message::CmmsSaveResult, + ); + } else { + crate::sys::download_bytes(&filename, &bytes); + } + let cache_name = std::path::Path::new(&filename) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| filename.clone()); + let path = std::path::PathBuf::from(cache_name); + self.tabs[i].current_path = Some(path.clone()); + self.tabs[i].scene.document.version = version; + self.tabs[i].dirty = false; + self.tabs[i].recovery_save_as_required = false; + recent_task = Task::perform( + async move { + crate::io::web_recent::store(&path.to_string_lossy(), &bytes) + .await + .map(|_| path) + }, + Message::WebRecentStored, + ); + self.command_line + .push_output(crate::tf!("Saved: {filename}").as_ref()); + true + } + Err(error) => { + self.command_line + .push_error(crate::tf!("Save failed: {error}").as_ref()); + false + } + }; + if self.save_dialog_for_unsaved { + if saved { + if let Some(crate::app::PendingClose::Tab(index)) = self.pending_close.take() { + let continuation = self.update(Message::TabClose(index)); + let rest = self.continue_tab_close_queue(); + return Task::batch([recent_task, cmms_save_task, continuation, rest]); + } + } else if self.pending_close.is_some() { + let retry = self.open_unsaved_dialog_window(); + return Task::batch([recent_task, cmms_save_task, retry]); + } + } + Task::batch([recent_task, cmms_save_task]) } #[cfg(not(target_arch = "wasm32"))] @@ -1494,11 +1774,27 @@ pub(super) fn on_open_file(&mut self) -> Task { check_external_change: bool, ) -> Task { let tab_id = self.tabs[i].id; + if self + .pending_native_thumbnail_save + .as_ref() + .is_some_and(|pending| pending.tab_id == tab_id) + { + if purpose != crate::app::SavePurpose::Autosave { + self.command_line + .push_info(crate::t!("Save already running for this drawing.").as_ref()); + } + return Task::none(); + } + let capture_ready = + self.thumbnail_capture_clean && self.pending_native_thumbnail_save.is_none(); if self.active_save_jobs.contains_key(&tab_id) { if purpose != crate::app::SavePurpose::Autosave { self.command_line .push_info(crate::t!("Save already running for this drawing.").as_ref()); } + if capture_ready { + self.thumbnail_capture_clean = false; + } return Task::none(); } let destination_is_current = self.tabs[i] @@ -1516,6 +1812,9 @@ pub(super) fn on_open_file(&mut self) -> Task { self.active_tab = i; self.save_dialog_for_unsaved = continuation != crate::app::SaveContinuation::None; + if capture_ready { + self.thumbnail_capture_clean = false; + } return self.open_save_dialog_window(i); } if purpose != crate::app::SavePurpose::Autosave @@ -1540,6 +1839,33 @@ pub(super) fn on_open_file(&mut self) -> Task { }); self.restore_failed_save_continuation(continuation, i); self.active_modal = Some(crate::app::ModalKind::FileInUse); + if capture_ready { + self.thumbnail_capture_clean = false; + } + return Task::none(); + } + + if purpose != crate::app::SavePurpose::Autosave + && i == self.active_tab + && !self.thumbnail_capture_clean + && self.main_window.is_some() + && crate::ui::wrap_bar::dropdown_bounds( + crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, + ) + .is_some() + { + self.pending_native_thumbnail_save = Some( + crate::app::PendingNativeThumbnailSave { + tab_id, + path, + version, + purpose, + continuation, + set_current_path, + check_external_change, + }, + ); + self.thumbnail_capture_clean = true; return Task::none(); } @@ -1566,6 +1892,9 @@ pub(super) fn on_open_file(&mut self) -> Task { }); self.restore_failed_save_continuation(continuation, i); self.active_modal = Some(crate::app::ModalKind::FileInUse); + if capture_ready { + self.thumbnail_capture_clean = false; + } return Task::none(); } Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => { @@ -1579,32 +1908,15 @@ pub(super) fn on_open_file(&mut self) -> Task { let epoch = self.tabs[i].scene.geometry_epoch; let revision = self.tabs[i].edit_revision; let camera_generation = self.tabs[i].scene.camera_generation; - let thumbnail_key = (purpose != crate::app::SavePurpose::Autosave).then(|| { - let scene = &self.tabs[i].scene; - crate::app::ThumbnailCacheKey { - epoch, - camera_generation, - bg_color: scene.bg_color.map(f32::to_bits), - png: version >= acadrust::DxfVersion::AC1027, - viewport: [self.vp_size.0.to_bits(), self.vp_size.1.to_bits()], - } + let thumbnail = (purpose != crate::app::SavePurpose::Autosave && i == self.active_tab) + .then_some(version >= acadrust::DxfVersion::AC1027); + let capture_bounds = thumbnail.and_then(|_| { + crate::ui::wrap_bar::dropdown_bounds( + crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, + ) }); - let thumbnail = if thumbnail_key == self.tabs[i].thumbnail_cache_key { - None - } else { - thumbnail_key.map(|key| { - let scene = &self.tabs[i].scene; - ( - scene.entity_wires(), - scene.camera.borrow().clone(), - scene.bg_color, - key.png, - self.vp_size, - ) - }) - }; let clone_started = iced::time::Instant::now(); - let mut snapshot = self.tabs[i].scene.document.clone(); + let snapshot = self.tabs[i].scene.document.clone(); let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0; if crate::perf::enabled() { crate::perf_record!( @@ -1650,6 +1962,9 @@ pub(super) fn on_open_file(&mut self) -> Task { let (expected_fingerprint, verify_reader) = match verification { Ok(verification) => verification, Err(error) => { + if capture_ready { + self.thumbnail_capture_clean = false; + } return Task::perform( async move { crate::app::SaveOutcome { @@ -1664,7 +1979,6 @@ pub(super) fn on_open_file(&mut self) -> Task { set_current_path, purpose, continuation, - thumbnail_key, refreshed_preview: None, result: Err(error), } @@ -1674,65 +1988,97 @@ pub(super) fn on_open_file(&mut self) -> Task { } }; let worker_path = path.clone(); - - Task::perform( - async move { - let (result, refreshed_preview) = std::thread::spawn(move || { - let mut refreshed_preview = None; - if let Some((wires, camera, bg_color, png, viewport)) = thumbnail { - let started = iced::time::Instant::now(); - snapshot.preview = crate::io::thumbnail::from_snapshot( - &wires, - &camera, - bg_color, - png, - viewport, - ); - if crate::perf::enabled() { - crate::perf_record!( - "[perf] save-thumbnail {:.1}ms wires={}", - started.elapsed().as_secs_f64() * 1000.0, - wires.len(), - ); + let capture_window = thumbnail.and(capture_bounds).and(self.main_window); + let mut work = Some(( + snapshot, + thumbnail, + capture_bounds, + worker_path, + expected_fingerprint, + verify_reader, + path, + previous_autosave, + )); + let mut run_save = move |screenshot: Option| { + let ( + mut snapshot, + thumbnail, + capture_bounds, + worker_path, + expected_fingerprint, + verify_reader, + path, + previous_autosave, + ) = work.take().expect("save capture produced more than one result"); + Task::perform( + async move { + let (result, refreshed_preview) = std::thread::spawn(move || { + let mut refreshed_preview = None; + if let Some(png) = thumbnail { + let started = iced::time::Instant::now(); + if let Some(preview) = screenshot.as_ref().and_then(|screenshot| { + capture_bounds.and_then(|bounds| { + crate::io::thumbnail::from_screenshot(screenshot, bounds, png) + }) + }) { + snapshot.preview = Some(preview); + refreshed_preview = Some(snapshot.preview.clone()); + } + if crate::perf::enabled() { + crate::perf_record!( + "[perf] save-thumbnail {:.1}ms", + started.elapsed().as_secs_f64() * 1000.0, + ); + } } - refreshed_preview = Some(snapshot.preview.clone()); - } - let result = crate::io::save_owned_as_version_atomic( - snapshot, - &worker_path, + let result = crate::io::save_owned_as_version_atomic( + snapshot, + &worker_path, + version, + backup, + expected_fingerprint, + verify_reader, + ); + (result, refreshed_preview) + }) + .join() + .unwrap_or_else(|_| { + ( + Err(crate::io::SaveFailure::other("save worker panicked")), + None, + ) + }); + crate::app::SaveOutcome { + job_id, + tab_id, + epoch, + revision, + camera_generation, + path, version, - backup, - expected_fingerprint, - verify_reader, - ); - (result, refreshed_preview) - }) - .join() - .unwrap_or_else(|_| { - ( - Err(crate::io::SaveFailure::other("save worker panicked")), - None, - ) - }); - crate::app::SaveOutcome { - job_id, - tab_id, - epoch, - revision, - camera_generation, - path, - version, - previous_autosave, - set_current_path, - purpose, - continuation, - thumbnail_key, - refreshed_preview, - result, - } - }, - Message::SaveFinished, - ) + previous_autosave, + set_current_path, + purpose, + continuation, + refreshed_preview, + result, + } + }, + Message::SaveFinished, + ) + }; + match capture_window { + Some(window) => iced::window::screenshot(window).map(Some).then(move |screenshot| { + Task::batch([ + Task::done(Message::ThumbnailCaptureFinished), + run_save(screenshot), + ]) + }), + None => { + self.thumbnail_capture_clean = false; + run_save(None) + } + } } #[cfg(not(target_arch = "wasm32"))] @@ -1836,7 +2182,6 @@ pub(super) fn on_open_file(&mut self) -> Task { if let Some(preview) = outcome.refreshed_preview { self.tabs[i].scene.document.preview = preview; } - self.tabs[i].thumbnail_cache_key = outcome.thumbnail_key; } let mut tasks = Vec::new(); match outcome.purpose { @@ -1850,10 +2195,9 @@ pub(super) fn on_open_file(&mut self) -> Task { .as_deref() .is_none_or(|current| { !native_paths_match(current, &outcome.path) - }); + }); self.command_line .push_output(crate::tf!("Saved: {}", outcome.path.display()).as_ref()); - self.recent_thumbs.remove(&outcome.path); if let Some(previous) = outcome.previous_autosave { if previous != outcome.path { let _ = std::fs::remove_file(previous); @@ -1865,8 +2209,8 @@ pub(super) fn on_open_file(&mut self) -> Task { if outcome.purpose == crate::app::SavePurpose::SaveAs { self.tabs[i].recovery_save_as_required = false; } - tasks.push(self.push_recent(outcome.path.clone())); } + tasks.push(self.push_recent(outcome.path.clone())); self.refresh_native_edit_guard_after_save( i, &outcome.path, @@ -2064,7 +2408,7 @@ pub(super) fn on_open_file(&mut self) -> Task { // once after the destination/version is known. #[cfg(target_arch = "wasm32")] { - self.sync_vport_display(i); + self.sync_view_state_for_save(i); self.stamp_header_sysvars(i); } // Native: save straight to the known path. Web has no path @@ -2214,76 +2558,38 @@ pub(super) fn on_open_file(&mut self) -> Task { #[cfg(target_arch = "wasm32")] { let close = self.close_save_dialog_window(); + self.sync_view_state_for_save(i); sync_annotation_scale_header(&mut self.tabs[i].scene); self.stamp_header_sysvars(i); - self.sync_solid_models_to_acis(i); - self.stamp_thumbnail(i, version); - let mut recent_task = Task::none(); - // CMMS embedding (not upstream): when launched from - // cad-edit.php, save POSTs back to the CMMS instead of - // triggering a browser download. Standalone use (no - // window.CAD_EDITOR_CONFIG) is unaffected. - let mut cmms_save_task = Task::none(); - let saved = match crate::io::save_to_bytes( - &self.tabs[i].scene.document, - ext, - version, - ) { - Ok(bytes) => { - if crate::sys::cmms_config().is_some() { - let post_bytes = bytes.clone(); - let post_filename = filename.clone(); - cmms_save_task = Task::perform( - async move { crate::sys::post_bytes_to_host(&post_bytes, &post_filename).await }, - Message::CmmsSaveResult, - ); - } else { - crate::sys::download_bytes(&filename, &bytes); - } - let cache_name = std::path::Path::new(&filename) - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| filename.clone()); - let path = std::path::PathBuf::from(cache_name); - self.tabs[i].current_path = Some(path.clone()); - self.tabs[i].scene.document.version = version; - self.tabs[i].dirty = false; - self.tabs[i].recovery_save_as_required = false; - recent_task = Task::perform( - async move { - crate::io::web_recent::store( - &path.to_string_lossy(), - &bytes, - ) - .await - .map(|_| path) - }, - Message::WebRecentStored, - ); - self.command_line.push_output(crate::tf!("Saved: {filename}").as_ref()); - true - } - Err(e) => { - self.command_line.push_error(crate::tf!("Save failed: {e}").as_ref()); - false - } + self.sync_solid_models_for_save(i); + let tab_id = self.tabs[i].id; + let bounds = crate::ui::wrap_bar::dropdown_bounds( + crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, + ); + let Some(bounds) = bounds else { + return Task::batch([ + close, + Task::done(Message::WebSaveScreenshot { + tab_id, + filename, + ext: ext.to_string(), + version, + bounds: None, + screenshot: None, + }), + ]); }; - // Continue a pending tab close. - if self.save_dialog_for_unsaved { - if saved { - if let Some(crate::app::PendingClose::Tab(idx)) = - self.pending_close.take() - { - let cont = self.update(Message::TabClose(idx)); - let rest = self.continue_tab_close_queue(); - return Task::batch([close, recent_task, cmms_save_task, cont, rest]); - } - } else if self.pending_close.is_some() { - let retry = self.open_unsaved_dialog_window(); - return Task::batch([close, recent_task, cmms_save_task, retry]); - } - } - Task::batch([close, recent_task, cmms_save_task]) + self.pending_web_thumbnail_save = Some( + crate::app::PendingWebThumbnailSave { + tab_id, + filename, + ext: ext.to_string(), + version, + bounds, + }, + ); + self.thumbnail_capture_clean = true; + close } } @@ -2394,6 +2700,7 @@ pub(super) fn on_open_file(&mut self) -> Task { { continue; } + self.prepare_native_save(i); let version = self.tabs[i].scene.document.version; let target = self.autosave_target(i); tasks.push(self.queue_native_save( @@ -2429,8 +2736,7 @@ pub(super) fn on_open_file(&mut self) -> Task { iced::exit() } - /// Write the given plot page settings into the active layout's Layout + - /// PlotSettings objects (paper size, plot area, offset, rotation, scale). + /// Write the given plot page settings into the active layout. /// No-op on the Model tab (which has no paper layout). Marks the tab dirty /// and re-tessellates the sheet. Called by the Plot dialog's Set current action. #[allow(clippy::too_many_arguments)] @@ -2451,118 +2757,97 @@ pub(super) fn on_open_file(&mut self) -> Task { if layout_name != "Model" { let w: f64 = w.max(1.0); let h: f64 = h.max(1.0); + use acadrust::objects::{ + PlotRotation, PlotSettings, PlotType, ScaledType, ShadePlotMode, + ShadePlotResolutionLevel, + }; + let mut ps = self + .plot_setup_template + .clone() + .or_else(|| self.tabs[i].scene.plot_settings_for(&layout_name)) + .unwrap_or_else(|| PlotSettings::new("")); + ps.paper_width = w; + ps.paper_height = h; + ps.paper_size = dialog.paper.clone(); + ps.plot_type = match plot_area { + "Window" => PlotType::Window, + "Display" => PlotType::LastScreenDisplay, + "Extents" => PlotType::Extents, + "Limits" => PlotType::Limits, + area if area.starts_with("View: ") => PlotType::View, + _ => PlotType::Layout, + }; + ps.plot_view_name = plot_area + .strip_prefix("View: ") + .unwrap_or("") + .to_string(); + if plot_area == "Window" { + if let Some((x0, y0, x1, y1)) = plot_window { + ps.set_plot_window(x0, y0, x1, y1); + } + } + ps.flags.plot_centered = center && plot_area != "Layout"; + ps.origin_x = if plot_area == "Layout" { 0.0 } else { offset_x }; + ps.origin_y = if plot_area == "Layout" { 0.0 } else { offset_y }; + ps.rotation = match rotation { + 90 => PlotRotation::Degrees90, + 180 => PlotRotation::Degrees180, + 270 => PlotRotation::Degrees270, + _ => PlotRotation::None, + }; + if dialog.fit_to_paper && plot_area != "Layout" { + ps.set_scale_to_fit(); + } else if plot_area == "Layout" { + ps.set_standard_scale(ScaledType::OneToOne); + ps.standard_scale_factor = 1.0; + } else { + let factor = plot_dialog_scale_factor(&dialog); + ps.scale_type = ScaledType::CustomScale; + ps.scale_numerator = factor; + ps.scale_denominator = 1.0; + ps.standard_scale_factor = factor; + ps.flags.use_standard_scale = false; + } + ps.printer_name = if dialog.to_file { + crate::ui::window::plot::OUT_PDF.into() + } else { + dialog.printer.clone().unwrap_or_default() + }; + ps.current_style_sheet = dialog.style_name.clone(); + ps.flags.scale_lineweights = dialog.scale_lw; + ps.flags.print_lineweights = dialog.lineweights; + ps.flags.plot_plot_styles = dialog.apply_plot_styles && !dialog.style_name.is_empty(); + ps.flags.show_plot_styles = dialog.show_plot_styles && !dialog.style_name.is_empty(); + ps.flags.draw_viewports_first = dialog.paperspace_last; + ps.flags.plot_hidden = dialog.shade == "Hidden Line"; + ps.shade_plot_mode = match dialog.shade.as_str() { + "2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe, + "Hidden Line" => ShadePlotMode::Hidden, + "As displayed" => ShadePlotMode::AsDisplayed, + _ => ShadePlotMode::Rendered, + }; + ps.shade_plot_resolution = match dialog.quality.as_str() { + "Low" => ShadePlotResolutionLevel::Draft, + "High" => ShadePlotResolutionLevel::Presentation, + _ => ShadePlotResolutionLevel::Normal, + }; + ps.shade_plot_dpi = 300; + + self.tabs[i] + .scene + .set_layout_plot_settings(&layout_name, &ps); for obj in self.tabs[i].scene.document.objects.values_mut() { - if let acadrust::objects::ObjectType::Layout(l) = obj { - if l.name == layout_name { - l.min_limits = (0.0, 0.0); - l.max_limits = (w, h); - l.min_extents = (0.0, 0.0, 0.0); - l.max_extents = (w, h, 0.0); - l.paper_width = w; - l.paper_height = h; - l.plot_rotation = 0; - l.plot_paper_units = 1; - l.plot_origin_x = offset_x; - l.plot_origin_y = offset_y; - l.paper_size = String::new(); + if let acadrust::objects::ObjectType::Layout(layout) = obj { + if layout.name == layout_name { + layout.min_limits = (0.0, 0.0); + layout.max_limits = (w, h); + layout.min_extents = (0.0, 0.0, 0.0); + layout.max_extents = (w, h, 0.0); break; } } } - use acadrust::objects::{ - ObjectType, PlotPaperUnits, PlotRotation, PlotSettings, PlotType, ScaledType, - ShadePlotMode, ShadePlotResolutionLevel, - }; - let plot_handle = self.tabs[i] - .scene - .document - .objects - .iter() - .find_map(|(h, obj)| { - if let ObjectType::PlotSettings(ps) = obj { - (ps.page_name == layout_name).then_some(*h) - } else { - None - } - }); - - let ps_entry = if let Some(h) = plot_handle { - self.tabs[i].scene.document.objects.get_mut(&h) - } else { - let mut ps = PlotSettings::new(layout_name.clone()); - ps.handle = self.tabs[i].scene.document.allocate_handle(); - let h = ps.handle; - self.tabs[i] - .scene - .document - .objects - .insert(h, ObjectType::PlotSettings(ps)); - self.tabs[i].scene.document.objects.get_mut(&h) - }; - - if let Some(ObjectType::PlotSettings(ps)) = ps_entry { - ps.paper_width = w; - ps.paper_height = h; - ps.paper_size = dialog.paper.clone(); - ps.paper_units = PlotPaperUnits::Millimeters; - ps.plot_type = match plot_area { - "Window" => PlotType::Window, - "Display" => PlotType::LastScreenDisplay, - "Extents" => PlotType::Extents, - _ => PlotType::Layout, - }; - if plot_area == "Window" { - if let Some((x0, y0, x1, y1)) = plot_window { - ps.set_plot_window(x0, y0, x1, y1); - } - } - ps.flags.plot_centered = center && plot_area != "Layout"; - ps.origin_x = if plot_area == "Layout" { 0.0 } else { offset_x }; - ps.origin_y = if plot_area == "Layout" { 0.0 } else { offset_y }; - ps.rotation = match rotation { - 90 => PlotRotation::Degrees90, - 180 => PlotRotation::Degrees180, - 270 => PlotRotation::Degrees270, - _ => PlotRotation::None, - }; - if dialog.fit_to_paper && plot_area != "Layout" { - ps.set_scale_to_fit(); - } else { - let factor = if plot_area == "Layout" { - 1.0 - } else { - plot_dialog_scale_factor(&dialog) - }; - ps.scale_type = ScaledType::CustomScale; - ps.scale_numerator = factor; - ps.scale_denominator = 1.0; - } - ps.printer_name = if dialog.to_file { - crate::ui::window::plot::OUT_PDF.into() - } else { - dialog.printer.clone().unwrap_or_default() - }; - ps.current_style_sheet = dialog.style_name.clone(); - ps.flags.scale_lineweights = dialog.scale_lw; - ps.flags.print_lineweights = dialog.lineweights; - ps.flags.plot_plot_styles = !dialog.style_name.is_empty(); - ps.flags.draw_viewports_first = dialog.paperspace_last; - ps.flags.plot_hidden = false; - ps.shade_plot_mode = match dialog.shade.as_str() { - "2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe, - "Hidden Line" => ShadePlotMode::Hidden, - "As displayed" => ShadePlotMode::AsDisplayed, - _ => ShadePlotMode::Rendered, - }; - ps.shade_plot_resolution = match dialog.quality.as_str() { - "Low" => ShadePlotResolutionLevel::Draft, - "High" => ShadePlotResolutionLevel::Presentation, - _ => ShadePlotResolutionLevel::Normal, - }; - ps.shade_plot_dpi = 300; - } - self.tabs[i].dirty = true; self.tabs[i].scene.bump_geometry_no_blocks(); self.command_line.push_info(crate::tf!( @@ -2576,6 +2861,19 @@ pub(super) fn on_open_file(&mut self) -> Task { &mut self, path: std::path::PathBuf, ) -> Task { + let i = self.active_tab; + if self.tabs[i].scene.current_layout != "Model" { + self.plot_dialog.paper_space = true; + self.plot_dialog.scales = self.tabs[i] + .scene + .scale_list() + .into_iter() + .map(|(name, _, factor)| (name, factor)) + .collect(); + if let Some(settings) = self.tabs[i].scene.effective_plot_settings() { + self.load_plotsettings_into_dialog(&settings); + } + } let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = self.direct_plot_params() else { @@ -2678,6 +2976,7 @@ pub(super) fn on_open_file(&mut self) -> Task { .filter(|name| name != "Model") .map(|name| (name, true)) .collect(); + self.print_all_settings_override = false; self.active_modal = Some(crate::app::ModalKind::PrintAll); self.reset_modal_geometry(); Task::none() @@ -2687,10 +2986,12 @@ pub(super) fn on_open_file(&mut self) -> Task { let previous = self.plot_dialog.clone(); let previous_style = self.active_plot_style.clone(); let previous_window = self.plot_window; + let previous_setup = self.plot_setup_template.clone(); let task = self.on_plot_dialog_open(); self.print_all_options_prev = Some(previous); self.print_all_plot_style_prev = Some(previous_style); self.print_all_plot_window_prev = Some(previous_window); + self.print_all_plot_setup_prev = Some(previous_setup); self.print_all_options = true; self.plot_dialog.paper_space = true; self.plot_dialog.area = "Layout".into(); @@ -2712,47 +3013,145 @@ pub(super) fn on_open_file(&mut self) -> Task { let i = self.active_tab; let original_layout = self.tabs[i].scene.current_layout.clone(); let original_viewport = self.tabs[i].scene.active_viewport; - let dialog = self.plot_dialog.clone(); - let mut pages = Vec::with_capacity(selected.len()); - for name in selected { - // Plot helpers read the active layout. Swap only this transient - // selector so the drawing's saved active-space metadata is not - // touched while the owned page snapshot is collected. - { - let scene = &mut self.tabs[i].scene; - scene.current_layout = name; - scene.active_viewport = None; + let original_psltscale = self.tabs[i] + .scene + .document + .header + .paper_space_linetype_scaling; + let original_plimcheck = self.tabs[i] + .scene + .document + .header + .paper_space_limit_check; + let original_dialog = self.plot_dialog.clone(); + let original_style = self.active_plot_style.clone(); + let original_window = self.plot_window; + let original_setup = self.plot_setup_template.clone(); + let original_camera = self.tabs[i].scene.camera.borrow().clone(); + let original_camera_generation = self.tabs[i].scene.camera_generation; + let override_dialog = self.print_all_settings_override.then(|| original_dialog.clone()); + let override_style = self.print_all_settings_override.then(|| original_style.clone()); + let result = (|| { + let mut pages = Vec::with_capacity(selected.len()); + for name in selected { + let page_setup = self.tabs[i] + .scene + .plot_settings_for(&name) + .ok_or_else(|| format!("Layout '{name}' has no page setup."))?; + { + let scene = &mut self.tabs[i].scene; + scene.current_layout = name.clone(); + scene.active_viewport = None; + scene.load_current_layout_state(); + } + if let Some(dialog) = &override_dialog { + self.plot_dialog = dialog.clone(); + self.active_plot_style = override_style.clone().flatten(); + self.plot_window = original_window; + } else { + self.plot_dialog.paper_space = true; + self.plot_window = None; + self.load_plotsettings_into_dialog(&page_setup); + } + let dialog = self.plot_dialog.clone(); + if dialog.area == "Display" { + self.tabs[i].scene.restore_saved_camera(); + } + if dialog.style_missing && dialog.apply_plot_styles { + return Err(format!( + "Layout '{name}' plot style table '{}' is not loaded.", + dialog.style_name + )); + } + let plot_style = self.dialog_plot_style(&dialog); + let params = match dialog.area.as_str() { + "Display" => self.display_plot_job(), + "Extents" => self.extents_plot_job(), + "Limits" => self.limits_plot_job(), + "Window" => self.window_plot_job(), + area if area.starts_with("View: ") => { + self.named_view_plot_job(area.trim_start_matches("View: ")) + } + _ => None, + }; + let ( + wires, + hatches, + wipeouts, + group_splits, + paper_w, + paper_h, + offset_x, + offset_y, + rotation_deg, + scale, + clip, + ) = if dialog.area == "Layout" { + self.layout_plot_params_for("Layout") + } else { + let ( + wires, + hatches, + wipeouts, + group_splits, + paper_w, + paper_h, + offset_x, + offset_y, + rotation_deg, + scale, + clip, + ) = params.ok_or_else(|| format!("Layout '{name}' plot area is empty."))?; + ( + std::sync::Arc::new(wires), + hatches, + wipeouts, + group_splits, + paper_w, + paper_h, + offset_x, + offset_y, + rotation_deg, + scale, + clip, + ) + }; + pages.push(crate::io::pdf_export::PdfPageInput { + wires, + hatches, + wipeouts, + paper_w, + paper_h, + offset_x, + offset_y, + rotation_deg, + scale, + clip, + options: Self::pdf_plot_options(&dialog, group_splits), + plot_style, + }); } - let ( - wires, - hatches, - wipeouts, - group_splits, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - ) = self.layout_plot_params_for("Layout"); - pages.push(crate::io::pdf_export::PdfPageInput { - wires, - hatches, - wipeouts, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - options: Self::pdf_plot_options(&dialog, group_splits), - }); - } + Ok(pages) + })(); self.tabs[i].scene.current_layout = original_layout; self.tabs[i].scene.active_viewport = original_viewport; - Ok(pages) + self.tabs[i] + .scene + .document + .header + .paper_space_linetype_scaling = original_psltscale; + self.tabs[i] + .scene + .document + .header + .paper_space_limit_check = original_plimcheck; + self.plot_dialog = original_dialog; + self.active_plot_style = original_style; + self.plot_window = original_window; + self.plot_setup_template = original_setup; + *self.tabs[i].scene.camera.borrow_mut() = original_camera; + self.tabs[i].scene.camera_generation = original_camera_generation; + result } pub(super) fn on_print_all_pdf_path_some( @@ -2760,7 +3159,10 @@ pub(super) fn on_open_file(&mut self) -> Task { path: std::path::PathBuf, ) -> Task { let dialog = self.plot_dialog.clone(); - if dialog.style_missing { + if self.print_all_settings_override + && dialog.style_missing + && dialog.apply_plot_styles + { self.command_line.push_error(crate::tf!( "Plot style table '{}' is not loaded.", dialog.style_name @@ -2774,7 +3176,6 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::none(); } }; - let plot_style = self.dialog_plot_style(&dialog); let worker_path = path.clone(); self.save_config(); self.close_active_modal(); @@ -2782,7 +3183,7 @@ pub(super) fn on_open_file(&mut self) -> Task { crate::io::pdf_export::export_pdf_pages( &pages, &worker_path, - plot_style.as_ref(), + None, ) .map(|_| format!("Exported {} layouts to {}", pages.len(), worker_path.display())) .map_err(|error| format!("Export failed: {error}")) @@ -2801,7 +3202,10 @@ pub(super) fn on_open_file(&mut self) -> Task { #[cfg(not(target_arch = "wasm32"))] { let dialog = self.plot_dialog.clone(); - if dialog.style_missing { + if self.print_all_settings_override + && dialog.style_missing + && dialog.apply_plot_styles + { self.command_line.push_error(crate::tf!( "Plot style table '{}' is not loaded.", dialog.style_name @@ -2815,15 +3219,8 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::none(); } }; - let plot_style = self.dialog_plot_style(&dialog); let options = self.plot_print_options(&dialog, Default::default()); - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0); - let temp_path = std::env::temp_dir().join(format!( - "open_cad_studio_print_all_{stamp}.pdf" - )); + let temp_path = crate::io::print_to_printer::temp_pdf_path("print_all"); self.save_config(); self.close_active_modal(); self.command_line.push_info( @@ -2833,7 +3230,7 @@ pub(super) fn on_open_file(&mut self) -> Task { crate::io::pdf_export::export_pdf_pages( &pages, &temp_path, - plot_style.as_ref(), + None, ) .and_then(|_| { crate::io::print_to_printer::print_existing_pdf(&temp_path, &options) @@ -2841,7 +3238,7 @@ pub(super) fn on_open_file(&mut self) -> Task { .map(|printer| format!("Sent {} layouts to printer: {printer}", pages.len())) .map_err(|error| format!("Print failed: {error}")) }; - self.run_print_all_work(dialog.background, work) + self.run_print_all_work(true, work) } } @@ -2885,23 +3282,10 @@ pub(super) fn on_open_file(&mut self) -> Task { } fn layout_plot_params_for(&self, plot_area: &str) -> LayoutPlotParams { - use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; let i = self.active_tab; let scene = &self.tabs[i].scene; let paper_space = scene.current_layout != "Model"; - let selected_paper = match self.plot_dialog.paper.as_str() { - "A3" => PaperSize::A3, - "A2" => PaperSize::A2, - "A1" => PaperSize::A1, - "A0" => PaperSize::A0, - _ => PaperSize::A4, - }; - let selected_orientation = if self.plot_dialog.orientation == "Portrait" { - Orientation::Portrait - } else { - Orientation::Landscape - }; - let selected_sheet = sheet_mm(selected_paper, selected_orientation); + let selected_sheet = plot_dialog_sheet_mm(&self.plot_dialog); let (source_wires, hatches, wipeouts, mut group_splits) = plot_scene_content( scene, @@ -3236,14 +3620,21 @@ pub(super) fn on_open_file(&mut self) -> Task { let one_to_one = scale_name_for_factor(&scales, 1.0) .or_else(|| scales.first().map(|(name, _)| name.clone())) .unwrap_or_else(|| "1:1".into()); - // Keep the user's last print preferences (printer, copies, quality, - // output options — persisted in `self.plot_dialog`); only refresh the - // runtime printer list and reseed the drawing-specific fields from the - // active layout. + let paper_space = self.tabs[self.active_tab].scene.current_layout != "Model"; + let plot_views = self.tabs[self.active_tab] + .scene + .document + .views + .iter() + .filter(|view| view.paper_space == paper_space) + .map(|view| view.name.clone()) + .collect(); + // Keep session-only choices while loading drawing fields from the layout. let d = &mut self.plot_dialog; d.printers = crate::io::print_to_printer::list_printers(); d.plot_styles = crate::io::plot_style::available_ctb_names(); d.scales = scales; + d.plot_views = plot_views; if d.scale.eq_ignore_ascii_case("fit") { d.fit_to_paper = true; d.scale_lw = false; @@ -3251,7 +3642,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } else if !d.scales.iter().any(|(name, _)| name == &d.scale) { d.scale = one_to_one.clone(); } - d.paper_space = self.tabs[self.active_tab].scene.current_layout != "Model"; + d.paper_space = paper_space; d.paper = self.plot_format.label().to_string(); d.orientation = match self.plot_orientation { Orientation::Portrait => "Portrait", @@ -3283,32 +3674,24 @@ pub(super) fn on_open_file(&mut self) -> Task { .map(|t| t.name.clone()) .unwrap_or_default(); d.style_missing = false; - // `area`, scale and fit are remembered user choices, - // not reseeded from the layout. Offset / center / rotation ARE layout - // properties, so reflect them. - if let Some(ps) = self.tabs[self.active_tab].scene.effective_plot_settings() { - let d = &mut self.plot_dialog; - d.center = ps.flags.plot_centered; - d.offset_x = format!("{:.2}", ps.origin_x); - d.offset_y = format!("{:.2}", ps.origin_y); - let deg = ps.rotation.to_degrees() as i32; - d.upside_down = matches!(deg, 180 | 270); - } self.plot_dialog.name_input = None; self.plot_dialog.name_rename = false; if self.plot_dialog.fit_to_paper { self.plot_dialog.scale_lw = false; } - // Refresh document/runtime lists, then restore the live choices from - // the preceding dialog session. Previously the snapshot happened - // after the layout values above were reloaded, so opening Plot itself - // destroyed the user's last paper, area, scale and output choices. self.refresh_page_setups(); self.plot_prev = Some(previous); - self.select_page_setup(crate::ui::window::plot::SETUP_PREV); - // `` may come from another tab/drawing whose custom scale is - // not present in this document. Validate again after restoring it; the - // pre-restore validation above only saw the temporarily reseeded state. + let cur = self.tabs[self.active_tab].scene.current_layout.clone(); + let layout_entry = format!("*{cur}*"); + if self.tabs[self.active_tab] + .scene + .plot_settings_for(&cur) + .is_some() + { + self.select_page_setup(&layout_entry); + } else { + self.select_page_setup(crate::ui::window::plot::SETUP_PREV); + } if self.plot_dialog.scale.eq_ignore_ascii_case("fit") { self.plot_dialog.fit_to_paper = true; self.plot_dialog.scale = one_to_one.clone(); @@ -3323,10 +3706,7 @@ pub(super) fn on_open_file(&mut self) -> Task { if self.plot_dialog.fit_to_paper { self.plot_dialog.scale_lw = false; } - // A model-space plot cannot use the paper-only Layout area. Other - // values remain exactly as the user left them; selecting a layout or - // named setup explicitly still reloads that setup below. - let cur = self.tabs[self.active_tab].scene.current_layout.clone(); + // A model-space plot cannot use the paper-only Layout area. if cur == "Model" { if self.plot_dialog.area == "Layout" { self.plot_dialog.area = "Window".into(); @@ -3381,10 +3761,16 @@ pub(super) fn on_open_file(&mut self) -> Task { } M::Paper(s) => { self.plot_dialog.paper = s; + let (w, h) = plot_dialog_sheet_mm(&self.plot_dialog); + self.plot_dialog.paper_width_mm = w; + self.plot_dialog.paper_height_mm = h; Task::none() } M::Orientation(s) => { self.plot_dialog.orientation = s; + let (w, h) = plot_dialog_sheet_mm(&self.plot_dialog); + self.plot_dialog.paper_width_mm = w; + self.plot_dialog.paper_height_mm = h; Task::none() } M::Area(s) => { @@ -3454,6 +3840,12 @@ pub(super) fn on_open_file(&mut self) -> Task { PlotFlag::ScaleLw if !d.fit_to_paper => { d.scale_lw = !d.scale_lw } + PlotFlag::PlotStyles if !d.style_name.is_empty() => { + d.apply_plot_styles = !d.apply_plot_styles + } + PlotFlag::DisplayStyles if d.paper_space && !d.style_name.is_empty() => { + d.show_plot_styles = !d.show_plot_styles + } PlotFlag::UpsideDown => { d.upside_down = !d.upside_down; } @@ -3473,11 +3865,14 @@ pub(super) fn on_open_file(&mut self) -> Task { if name == STYLE_NONE { self.active_plot_style = None; self.plot_dialog.style_name.clear(); + self.plot_dialog.apply_plot_styles = false; + self.plot_dialog.show_plot_styles = false; self.plot_dialog.style_missing = false; } else { match crate::io::plot_style::PlotStyleTable::load_named(&name) { Ok(table) => { self.plot_dialog.style_name = table.name.clone(); + self.plot_dialog.apply_plot_styles = true; self.plot_dialog.style_missing = false; self.active_plot_style = Some(table); } @@ -3625,7 +4020,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } M::Preview => self.on_plot_dlg_commit(true), M::Commit if self.print_all_options => { - if self.plot_dialog.style_missing { + if self.plot_dialog.style_missing && self.plot_dialog.apply_plot_styles { self.command_line.push_error(crate::tf!( "Plot style table '{}' is not loaded.", self.plot_dialog.style_name @@ -3636,9 +4031,11 @@ pub(super) fn on_open_file(&mut self) -> Task { self.plot_dialog.area = "Layout".into(); self.sync_dialog_plot_runtime(); self.save_config(); + self.print_all_settings_override = true; self.print_all_options = false; self.print_all_options_prev = None; self.print_all_plot_style_prev = None; + self.print_all_plot_setup_prev = None; if let Some(previous) = self.print_all_plot_window_prev.take() { self.plot_window = previous; } @@ -3667,12 +4064,15 @@ pub(super) fn on_open_file(&mut self) -> Task { use crate::ui::window::plot::{SETUP_NONE, SETUP_PREV}; self.plot_dialog.selected_setup = name.to_string(); if name == SETUP_NONE { + self.plot_setup_template = None; // No page setup: default geometry + PDF output. let is_model = self.tabs[self.active_tab].scene.current_layout == "Model"; let d = &mut self.plot_dialog; d.to_file = true; d.paper = "A4".into(); d.orientation = "Landscape".into(); + d.paper_width_mm = 297.0; + d.paper_height_mm = 210.0; d.area = if is_model { "Window".into() } else { "Layout".into() }; d.center = true; d.offset_x = "0.0".into(); @@ -3720,32 +4120,32 @@ pub(super) fn on_open_file(&mut self) -> Task { PlotPaperUnits, PlotRotation, PlotSettings, PlotType, ScaledType, ShadePlotMode, ShadePlotResolutionLevel, }; - use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; let d = &self.plot_dialog; - let paper = match d.paper.as_str() { - "A3" => PaperSize::A3, - "A2" => PaperSize::A2, - "A1" => PaperSize::A1, - "A0" => PaperSize::A0, - _ => PaperSize::A4, + let (w, h) = plot_dialog_sheet_mm(d); + let mut ps = match self.plot_setup_template.clone() { + Some(settings) => settings, + None => { + let mut settings = PlotSettings::new(""); + settings.paper_units = PlotPaperUnits::Millimeters; + settings + } }; - let orient = if d.orientation == "Portrait" { - Orientation::Portrait - } else { - Orientation::Landscape - }; - let (w, h) = sheet_mm(paper, orient); - let mut ps = PlotSettings::new(""); ps.paper_width = w; ps.paper_height = h; ps.paper_size = d.paper.clone(); - ps.paper_units = PlotPaperUnits::Millimeters; ps.plot_type = match d.area.as_str() { "Window" => PlotType::Window, "Layout" => PlotType::Layout, "Display" => PlotType::LastScreenDisplay, + "Limits" => PlotType::Limits, + area if area.starts_with("View: ") => PlotType::View, _ => PlotType::Extents, }; + ps.plot_view_name = d + .area + .strip_prefix("View: ") + .unwrap_or("") + .to_string(); if d.area == "Window" { if let Some((x0, y0, x1, y1)) = self.plot_window { ps.set_plot_window(x0, y0, x1, y1); @@ -3771,6 +4171,7 @@ pub(super) fn on_open_file(&mut self) -> Task { }; if d.area == "Layout" { ps.set_standard_scale(ScaledType::OneToOne); + ps.standard_scale_factor = 1.0; } else if d.fit_to_paper { ps.set_scale_to_fit(); } else { @@ -3778,6 +4179,8 @@ pub(super) fn on_open_file(&mut self) -> Task { ps.scale_type = ScaledType::CustomScale; ps.scale_numerator = factor; ps.scale_denominator = 1.0; + ps.standard_scale_factor = factor; + ps.flags.use_standard_scale = false; } ps.printer_name = if d.to_file { crate::ui::window::plot::OUT_PDF.into() @@ -3787,9 +4190,10 @@ pub(super) fn on_open_file(&mut self) -> Task { ps.current_style_sheet = d.style_name.clone(); ps.flags.scale_lineweights = d.scale_lw; ps.flags.print_lineweights = d.lineweights; - ps.flags.plot_plot_styles = !d.style_name.is_empty(); + ps.flags.plot_plot_styles = d.apply_plot_styles && !d.style_name.is_empty(); + ps.flags.show_plot_styles = d.show_plot_styles && !d.style_name.is_empty(); ps.flags.draw_viewports_first = d.paperspace_last; - ps.flags.plot_hidden = false; + ps.flags.plot_hidden = d.shade == "Hidden Line"; ps.shade_plot_mode = match d.shade.as_str() { "2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe, "Hidden Line" => ShadePlotMode::Hidden, @@ -3810,6 +4214,7 @@ pub(super) fn on_open_file(&mut self) -> Task { use acadrust::objects::{ PlotType, ShadePlotMode, ShadePlotResolutionLevel, }; + self.plot_setup_template = Some(ps.clone()); if matches!(ps.plot_type, PlotType::Window) && !ps.plot_window.is_empty() { self.plot_window = Some(( ps.plot_window.lower_left_x, @@ -3832,7 +4237,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } let active_style_name = self.active_plot_style.as_ref().map(|table| table.name.clone()); let style_name = if ps.current_style_sheet.is_empty() { - active_style_name.clone().unwrap_or_default() + String::new() } else if active_style_name .as_deref() .is_some_and(|name| name.eq_ignore_ascii_case(&ps.current_style_sheet)) @@ -3845,17 +4250,30 @@ pub(super) fn on_open_file(&mut self) -> Task { && active_style_name .as_deref() .is_some_and(|name| name.eq_ignore_ascii_case(&style_name)); - let (paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height); + let (mut paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height); + if !matches!(paper.as_str(), "A4" | "A3" | "A2" | "A1" | "A0") + && !ps.paper_size.is_empty() + { + paper = ps.paper_size.clone(); + } let d = &mut self.plot_dialog; d.paper = paper; + d.paper_width_mm = ps.paper_width.max(1.0); + d.paper_height_mm = ps.paper_height.max(1.0); d.orientation = orient; d.area = match ps.plot_type { - PlotType::Window => "Window", - PlotType::Layout => "Layout", - PlotType::LastScreenDisplay => "Display", - _ => "Extents", - } - .to_string(); + PlotType::Window => "Window".to_string(), + PlotType::Layout => "Layout".to_string(), + PlotType::LastScreenDisplay => "Display".to_string(), + PlotType::Limits => "Limits".to_string(), + PlotType::View if !ps.plot_view_name.is_empty() => { + d.plot_views.push(ps.plot_view_name.clone()); + d.plot_views.sort(); + d.plot_views.dedup(); + format!("View: {}", ps.plot_view_name) + } + _ => "Extents".to_string(), + }; if !d.paper_space && d.area == "Layout" { d.area = "Extents".into(); } @@ -3875,6 +4293,12 @@ pub(super) fn on_open_file(&mut self) -> Task { d.fit_to_paper = d.area != "Layout" && ps.is_scale_to_fit(); let target_factor = if d.area == "Layout" { 1.0 + } else if ps.flags.use_standard_scale { + if ps.standard_scale_factor.is_finite() && ps.standard_scale_factor > 0.0 { + ps.standard_scale_factor + } else { + ps.scale_type.scale_factor() + } } else if ps.scale_denominator.abs() > 1e-9 { ps.scale_numerator / ps.scale_denominator } else { @@ -3889,11 +4313,15 @@ pub(super) fn on_open_file(&mut self) -> Task { d.scale_lw = ps.flags.scale_lineweights && !d.fit_to_paper; d.lineweights = ps.flags.print_lineweights; d.paperspace_last = ps.flags.draw_viewports_first; - d.shade = match ps.shade_plot_mode { - ShadePlotMode::Wireframe => "2D Wireframe", - ShadePlotMode::Hidden => "Hidden Line", - ShadePlotMode::Rendered => "Gouraud Shaded", - ShadePlotMode::AsDisplayed => "As displayed", + d.shade = if ps.flags.plot_hidden { + "Hidden Line" + } else { + match ps.shade_plot_mode { + ShadePlotMode::Wireframe => "2D Wireframe", + ShadePlotMode::Hidden => "Hidden Line", + ShadePlotMode::Rendered => "Gouraud Shaded", + ShadePlotMode::AsDisplayed => "As displayed", + } } .into(); d.quality = match ps.shade_plot_resolution { @@ -3912,6 +4340,8 @@ pub(super) fn on_open_file(&mut self) -> Task { d.printer = (!ps.printer_name.is_empty()).then(|| ps.printer_name.clone()); } d.style_name = style_name; + d.apply_plot_styles = ps.flags.plot_plot_styles; + d.show_plot_styles = ps.flags.show_plot_styles; d.style_missing = !d.style_name.is_empty() && !style_loaded; } @@ -3937,10 +4367,9 @@ pub(super) fn on_open_file(&mut self) -> Task { } fn apply_dialog_to_layout(&mut self) { - use crate::io::paper_sizes::sheet_mm; self.sync_dialog_plot_runtime(); let d = self.plot_dialog.clone(); - let (sheet_w, sheet_h) = sheet_mm(self.plot_format, self.plot_orientation); + let (sheet_w, sheet_h) = plot_dialog_sheet_mm(&d); let rotation: i16 = if d.upside_down { 180 } else { 0 }; let off_x = d.offset_x.parse::().unwrap_or(0.0); let off_y = d.offset_y.parse::().unwrap_or(0.0); @@ -3958,7 +4387,7 @@ pub(super) fn on_open_file(&mut self) -> Task { /// Open a preview PDF, export a PDF, or send the job to the chosen printer. fn on_plot_dlg_commit(&mut self, preview: bool) -> Task { let d = self.plot_dialog.clone(); - if d.style_missing { + if d.style_missing && d.apply_plot_styles { self.command_line.push_error(crate::tf!( "Plot style table '{}' is not loaded.", d.style_name @@ -3976,11 +4405,16 @@ pub(super) fn on_open_file(&mut self) -> Task { let plot_style = self.dialog_plot_style(&d); // Extents, Window and Display use one plot path in both spaces. Only // Paper-space Layout is special: it uses the physical sheet bounds. - if matches!(d.area.as_str(), "Extents" | "Window" | "Display") { + if d.area != "Layout" { let job = match d.area.as_str() { "Display" => self.display_plot_job(), "Extents" => self.extents_plot_job(), - _ => self.window_plot_job(), + "Limits" => self.limits_plot_job(), + "Window" => self.window_plot_job(), + area if area.starts_with("View: ") => { + self.named_view_plot_job(area.trim_start_matches("View: ")) + } + _ => None, }; let Some(( w_wires, @@ -4004,7 +4438,7 @@ pub(super) fn on_open_file(&mut self) -> Task { // Preview renders to a temp PDF and opens it — never a save dialog, // whatever the output target is. if preview { - let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf"); + let tmp = crate::io::print_to_printer::temp_pdf_path("preview"); let render_options = Self::pdf_plot_options(&d, wgroup_splits); let work = move || { crate::io::pdf_export::export_pdf( @@ -4020,7 +4454,7 @@ pub(super) fn on_open_file(&mut self) -> Task { // Tested clipped export (opens a save dialog). return Task::done(Message::PlotWindowExport); } - let tmp = std::env::temp_dir().join("open_cad_studio_print.pdf"); + let tmp = crate::io::print_to_printer::temp_pdf_path("print"); let render_options = Self::pdf_plot_options(&d, wgroup_splits); let opts = self.plot_print_options(&d, wgroup_splits); let work = move || { @@ -4032,14 +4466,14 @@ pub(super) fn on_open_file(&mut self) -> Task { .map(|printer| format!("Sent to printer: {printer}")) .map_err(|e| format!("Print failed: {e}")) }; - return self.run_plot_work(d.background, false, work); + return self.run_plot_work(true, false, work); } let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) = self.layout_plot_params(); if preview { - let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf"); + let tmp = crate::io::print_to_printer::temp_pdf_path("preview"); let render_options = Self::pdf_plot_options(&d, group_splits); let work = move || { crate::io::pdf_export::export_pdf( @@ -4078,7 +4512,7 @@ pub(super) fn on_open_file(&mut self) -> Task { .map(|printer| format!("Sent to printer: {printer}")) .map_err(|error| format!("Print failed: {error}")) }; - self.run_plot_work(d.background, false, work) + self.run_plot_work(true, false, work) } /// Build a [`PrintOptions`](crate::io::print_to_printer::PrintOptions) from @@ -4114,7 +4548,7 @@ pub(super) fn on_open_file(&mut self) -> Task { &self, d: &crate::ui::window::plot::PlotDialogState, ) -> Option { - if d.style_name.is_empty() || d.style_missing { + if d.style_name.is_empty() || d.style_missing || !d.apply_plot_styles { return None; } self.active_plot_style @@ -4131,6 +4565,34 @@ pub(super) fn on_open_file(&mut self) -> Task { self.area_plot_job(self.display_plot_window()?) } + fn limits_plot_job(&self) -> Option { + let (min, max) = self.tabs[self.active_tab] + .scene + .current_drawing_limits()?; + self.area_plot_job((min.x, min.y, max.x, max.y)) + } + + fn named_view_plot_job(&self, name: &str) -> Option { + let view = self.tabs[self.active_tab] + .scene + .document + .views + .iter() + .find(|view| { + view.name.eq_ignore_ascii_case(name) + && view.paper_space == (self.tabs[self.active_tab].scene.current_layout != "Model") + })?; + let half_w = view.width.abs() * 0.5; + let half_h = view.height.abs() * 0.5; + (half_w > 1e-9 && half_h > 1e-9).then_some(())?; + self.area_plot_job(( + view.center.x - half_w, + view.center.y - half_h, + view.center.x + half_w, + view.center.y + half_h, + )) + } + fn extents_plot_job(&self) -> Option { let scene = &self.tabs[self.active_tab].scene; if scene.current_layout == "Model" { @@ -4219,13 +4681,13 @@ pub(super) fn on_open_file(&mut self) -> Task { /// Render a selected rectangle through one shared Model/Paper path. The /// window may lie partly or wholly outside a paper sheet. fn area_plot_job(&self, window: (f64, f64, f64, f64)) -> Option { - use crate::io::paper_sizes::{sheet_mm, window_to_sheet, PlotScale}; + use crate::io::paper_sizes::{window_to_sheet, PlotScale}; let i = self.active_tab; let (x0, y0, x1, y1) = window; if (x1 - x0) < 1e-6 || (y1 - y0) < 1e-6 { return None; } - let (sheet_w, sheet_h) = sheet_mm(self.plot_format, self.plot_orientation); + let (sheet_w, sheet_h) = plot_dialog_sheet_mm(&self.plot_dialog); let win_w = (x1 - x0).max(1e-9); let win_h = (y1 - y0).max(1e-9); let scale_sel = if self.plot_dialog.fit_to_paper { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 140fbc07..a75e1a7d 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -151,6 +151,9 @@ impl OpenCADStudio { if let Some(previous) = self.print_all_plot_window_prev.take() { self.plot_window = previous; } + if let Some(previous) = self.print_all_plot_setup_prev.take() { + self.plot_setup_template = previous; + } self.print_all_options = false; self.active_modal = Some(PrintAll); self.reset_modal_geometry(); @@ -192,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(), @@ -231,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 { let perf_started = crate::perf::enabled().then(Instant::now); let perf_label = perf_message_label(&msg); @@ -275,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). @@ -1573,9 +1601,38 @@ impl OpenCADStudio { self.command_line.input = s; return Task::batch(vec![sweep, self.update(Message::CommandSubmit)]); } - self.command_line.input = s; + let live_input = s.clone(); + self.command_line.input = live_input.clone(); self.command_line.autocomplete_cursor = None; self.command_line.cancel_history_navigation(); + // Live incremental search for INSERT/MINSERT: update picker on each keystroke + // without requiring Enter. Performance-first: uses upper/lower caches + // and partial sort (O(k)) so per-keystroke is <0.2ms even for 10k blocks. + // Suggestion count is in direct relation to CLIPROMPTLINES (picker limit). + { + let i = self.active_tab; + // Capture prompt/options while holding cmd borrow, then release before + // borrowing command_line to satisfy borrow checker. + let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if cmd.on_live_input(&live_input) { + (true, cmd.options(), cmd.prompt()) + } else { + (false, Vec::new(), String::new()) + } + } else { + (false, Vec::new(), String::new()) + }; + if should_update { + self.command_line.set_step_options(opts); + // Update pinned prompt text live without pushing new history entry + // (avoids flooding history with one entry per keystroke). + if let Some(last) = self.command_line.history.last_mut() { + if last.pinned { + last.text = prompt; + } + } + } + } Task::batch(vec![sweep, Task::none()]) } @@ -3792,6 +3849,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; @@ -3966,10 +4057,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) => { @@ -4001,26 +4089,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; @@ -4247,7 +4437,7 @@ impl OpenCADStudio { .push_error(crate::t!("DRAWORDER: select entities first.").as_ref()); } else { use crate::command::CadCommand; - let cmd = super::commands::DrawOrderRefCommand::new(to_move, above); + let cmd = super::commands::DrawOrderCommand::for_reference_pick(to_move, above); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } @@ -4769,6 +4959,47 @@ impl OpenCADStudio { } } } + "tbl_title_suppressed" | "tbl_header_suppressed" => { + let next = { + let document = &self.tabs[i].scene.document; + let Some(acadrust::EntityType::Table(table)) = + document.get_entity(handle) + else { + continue; + }; + let table_style = table.table_style_handle.and_then(|style_handle| { + document.objects.get(&style_handle).and_then(|object| { + match object { + acadrust::objects::ObjectType::TableStyle(style) => { + Some(style) + } + _ => None, + } + }) + }); + let current = if field == "tbl_title_suppressed" { + crate::entities::table::resolved_title_suppressed( + table, + table_style, + ) + } else { + crate::entities::table::resolved_header_suppressed( + table, + table_style, + ) + }; + !current + }; + if let Some(entity) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + crate::scene::view::dispatch::apply_geom_prop( + entity, + field, + if next { "true" } else { "false" }, + ); + } + } _ => { if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) @@ -4780,6 +5011,15 @@ impl OpenCADStudio { } } } + if field.starts_with("tbl_") { + for &handle in &handles { + if let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + table.block_record_handle = None; + } + } + } self.invalidate_property_targets(i, &handles); self.tabs[i].dirty = true; self.refresh_properties(); @@ -4795,6 +5035,9 @@ impl OpenCADStudio { match self.tabs[i].scene.document.get_entity(handles[0]) { Some(acadrust::EntityType::LwPolyline(p)) => p.vertices.len(), Some(acadrust::EntityType::Polyline2D(p)) => p.vertices.len(), + Some(acadrust::EntityType::Table(table)) => { + table.row_count().saturating_mul(table.column_count()) + } _ => 0, } } else { @@ -4806,6 +5049,7 @@ impl OpenCADStudio { let next = (cur + delta as i64).rem_euclid(n as i64) as usize; self.tabs[i].properties.prop_vertex = next; self.tabs[i].properties.prop_vertex_indicator_active = next != cur as usize; + crate::entities::table::set_prop_current_cell(next); self.refresh_properties(); } Task::none() @@ -4952,8 +5196,25 @@ impl OpenCADStudio { // line with the rest of the dim-colour stack (index-only through // the file layer). Guarded to leaders / dimensions so a mixed // selection can't stamp the override onto other entities. - if field == "dim_line_color" { + if matches!( + field.as_str(), + "dim_line_color" + | "dim_ext_line_color" + | "dim_text_color" + | "dim_text_fill_color" + ) { + let fill_mode = (field == "dim_text_fill_color").then(|| match color { + acadrust::types::Color::None => 0, + acadrust::types::Color::ByBlock => 1, + _ => 2, + }); let aci = color.approximate_index(); + let code = match field.as_str() { + "dim_ext_line_color" => crate::entities::dim_override::DIMCLRE, + "dim_text_color" => crate::entities::dim_override::DIMCLRT, + "dim_text_fill_color" => crate::entities::dim_override::DIMTFILLCLR, + _ => crate::entities::dim_override::DIMCLRD, + }; let targets: Vec = handles .iter() .copied() @@ -4968,10 +5229,23 @@ impl OpenCADStudio { if !targets.is_empty() { self.push_undo_snapshot(i, "CHPROP"); for &handle in &targets { + if field == "dim_text_fill_color" { + crate::entities::dim_override::set( + &mut self.tabs[i].scene.document, + handle, + crate::entities::dim_override::DIMTFILL, + Some(acadrust::xdata::XDataValue::Integer16( + fill_mode.unwrap_or(2), + )), + ); + if fill_mode != Some(2) { + continue; + } + } crate::entities::dim_override::set( &mut self.tabs[i].scene.document, handle, - crate::entities::dim_override::DIMCLRD, + code, Some(acadrust::xdata::XDataValue::Integer16(aci)), ); } @@ -4982,6 +5256,57 @@ impl OpenCADStudio { } return Task::none(); } + if matches!( + field.as_str(), + "tbl_cell_content_color" | "tbl_cell_background_color" + ) { + let cell_index = self.tabs[i].properties.prop_vertex; + if !handles.is_empty() { + self.push_undo_snapshot(i, "TABLE CELL COLOR"); + for &handle in &handles { + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + else { + continue; + }; + let columns = table.column_count(); + if columns == 0 { + continue; + } + if let Some(cell) = table.cell_mut( + cell_index / columns, + cell_index % columns, + ) { + use acadrust::entities::table::CellStateFlags; + if cell.state.intersects( + CellStateFlags::FORMAT_LOCKED + | CellStateFlags::FORMAT_READ_ONLY, + ) { + continue; + } + let style = cell.style.get_or_insert_with(Default::default); + if field == "tbl_cell_content_color" { + style.content_color = color; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::CONTENT_COLOR, + ); + } else { + style.background_color = color; + style.fill_enabled = true; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::BACKGROUND_COLOR, + ); + } + } + table.block_record_handle = None; + } + self.invalidate_property_targets(i, &handles); + self.tabs[i].properties.open_color_field = None; + self.tabs[i].dirty = true; + self.refresh_properties(); + } + return Task::none(); + } if !handles.is_empty() { self.push_undo_snapshot(i, "CHPROP"); let idx = if field == "gradient_color_2" { 1 } else { 0 }; @@ -6438,9 +6763,33 @@ impl OpenCADStudio { Message::AutoSave => self.on_autosave(), + Message::ThumbnailCaptureFrame => self.on_thumbnail_capture_frame(), + + Message::ThumbnailCaptureFinished => { + self.thumbnail_capture_clean = false; + Task::none() + } + #[cfg(not(target_arch = "wasm32"))] Message::SaveFinished(outcome) => self.on_save_finished(outcome), + #[cfg(target_arch = "wasm32")] + Message::WebSaveScreenshot { + tab_id, + filename, + ext, + version, + bounds, + screenshot, + } => self.on_web_save_screenshot( + tab_id, + filename, + ext, + version, + bounds, + screenshot, + ), + #[cfg(not(target_arch = "wasm32"))] Message::SaveFileInUseRetry => self.on_save_file_in_use_retry(), @@ -6847,6 +7196,9 @@ impl OpenCADStudio { self.plot_dialog.style_missing = false; self.plot_dialog.plot_styles = crate::io::plot_style::available_ctb_names(); + self.tabs[self.active_tab] + .scene + .invalidate_display_plot_style(); self.command_line.push_output( crate::tf!( @@ -6898,6 +7250,9 @@ impl OpenCADStudio { self.plot_dialog.style_missing = false; self.plot_dialog.plot_styles = crate::io::plot_style::available_ctb_names(); + self.tabs[self.active_tab] + .scene + .invalidate_display_plot_style(); self.command_line.push_output(crate::tf!( "Plot style table saved to \"{}\".", path.display() @@ -7621,6 +7976,7 @@ impl OpenCADStudio { let i = self.active_tab; self.tabs[i].properties.color_picker_open = false; + self.tabs[i].properties.open_color_field = None; self.tabs[i].layers.color_picker_row = None; Task::none() diff --git a/src/app/update/style.rs b/src/app/update/style.rs index 4a13eb63..3574338d 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -1146,6 +1146,9 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { Some(crate::app::ColorPickTarget::PropertiesBg) => { Some(Message::PropBgColorChanged(color)) } + Some(crate::app::ColorPickTarget::PropertiesField(field)) => { + Some(Message::PropColorFieldChanged { field, color }) + } Some(crate::app::ColorPickTarget::MText) => { Some(Message::MTextColorChanged(color)) } diff --git a/src/app/update/util.rs b/src/app/update/util.rs index 3a01d394..8779ffc2 100644 --- a/src/app/update/util.rs +++ b/src/app/update/util.rs @@ -62,8 +62,7 @@ pub(super) fn layout_entry_name(s: &str) -> &str { s.trim_start_matches('*').trim_end_matches('*') } -/// Infer the closest A-series paper label and orientation from sheet -/// dimensions (mm). Falls back to A4 when nothing is close. +/// Infer an A-series label when dimensions match; otherwise retain the size. pub(super) fn paper_label_from_dims(w: f64, h: f64) -> (String, String) { use crate::io::paper_sizes::PaperSize; let orient = if w >= h { "Landscape" } else { "Portrait" }; @@ -76,7 +75,12 @@ pub(super) fn paper_label_from_dims(w: f64, h: f64) -> (String, String) { best = (p.label().to_string(), err); } } - (best.0, orient.to_string()) + let label = if best.1 <= 2.0 { + best.0 + } else { + format!("{w:.2} × {h:.2} mm") + }; + (label, orient.to_string()) } pub(super) fn parse_plot_scale(s: &str) -> (f64, f64) { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 35bcecbd..34eddd24 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -809,6 +809,7 @@ impl OpenCADStudio { // render mode; the model-layout tab style is untouched. if self.tabs[i].scene.set_active_viewport_render_mode(mode) { self.tabs[i].scene.bump_geometry_no_blocks(); + self.tabs[i].dirty = true; self.command_line .push_output(crate::tf!("Viewport visual style: {label}").as_ref()); return Task::none(); @@ -827,6 +828,7 @@ impl OpenCADStudio { // Re-upload face3d fills on the next frame — the render // pipeline keys its upload cache off `geometry_epoch`. self.tabs[i].scene.bump_geometry_no_blocks(); + self.tabs[i].dirty = true; self.command_line .push_output(crate::tf!("Visual style: {label}").as_ref()); Task::none() @@ -2240,6 +2242,7 @@ impl OpenCADStudio { let guide = crate::scene::WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -3452,6 +3455,38 @@ impl OpenCADStudio { })? }); if let Some(handle) = hit { + let uses_surface_point = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.entity_pick_uses_surface_point()); + let entity_pick_point = if uses_surface_point { + self.tabs[i] + .scene + .solid_click_point_for(p, view_rot2, eye2, bounds, handle) + .unwrap_or(pick_wcs) + } else { + pick_wcs + }; + let entity_pick_direction = if uses_surface_point { + if matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Solid3D(_)) + ) { + self.tabs[i] + .scene + .solid_planar_face_normal_at(handle, entity_pick_point) + } else { + self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(crate::entities::curve::entity_curve) + .and_then(|curve| curve.plane.normal()) + .map(glam::DVec3::from_array) + } + } else { + None + }; // Some commands (e.g. SS_CATCHMENT) need the entity // body before `on_entity_pick` can advance. let inject_first = self.tabs[i] @@ -3482,7 +3517,8 @@ impl OpenCADStudio { let result = self.tabs[i].active_cmd.as_mut().map(|c| { // Shift-swap state for TRIM/EXTEND (#336). c.set_shift(shift); - c.on_entity_pick(handle, pick_wcs) + c.set_entity_pick_direction(entity_pick_direction); + c.on_entity_pick(handle, entity_pick_point) }); // HATCHEDIT: after pick, inject hatch model data into the command. if self.tabs[i] @@ -4009,8 +4045,7 @@ impl OpenCADStudio { ) }) .or_else(|| { - // Block-internal hatch: resolve to the - // parent Insert (AutoCAD behaviour). + // Block-internal hatch: resolve to the parent Insert. scene::pick::hit_test::click_hit_insert_hatch( p, self.tabs[i].scene.insert_hatches_for_click().as_ref(), @@ -4357,6 +4392,104 @@ impl OpenCADStudio { ).as_ref()); return Task::none(); } + let table_editor = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| match entity { + AcadEntityType::Table(table) => { + let horizontal = glam::DVec3::new( + table.horizontal_direction.x, + table.horizontal_direction.y, + table.horizontal_direction.z, + ) + .normalize_or(glam::DVec3::X); + let normal = glam::DVec3::new( + table.normal.x, + table.normal.y, + table.normal.z, + ) + .normalize_or(glam::DVec3::Z); + let mut down = horizontal + .cross(normal) + .normalize_or(glam::DVec3::NEG_Y); + let table_style = table.table_style_handle.and_then(|style_handle| { + self.tabs[i] + .scene + .document + .objects + .get(&style_handle) + .and_then(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) => { + Some(style) + } + _ => None, + }) + }); + let flows_up = crate::entities::table::resolved_flow_up( + table, + table_style, + ); + if flows_up { + down = -down; + } + let origin = glam::DVec3::new( + table.insertion_point.x, + table.insertion_point.y, + table.insertion_point.z, + ); + let relative = click_world - origin; + let x = relative.dot(horizontal); + let y = relative.dot(down); + if x < 0.0 || y < 0.0 { + return None; + } + let column = table + .columns + .iter() + .scan(0.0, |offset, column| { + *offset += column.width; + Some(*offset) + }) + .position(|end| x <= end)?; + let row = table + .rows + .iter() + .scan(0.0, |offset, row| { + *offset += row.height; + Some(*offset) + }) + .position(|end| y <= end)?; + let locked = table.cell(row, column).is_some_and(|cell| { + use acadrust::entities::table::CellStateFlags; + cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) + }); + Some(( + (!locked).then(|| { + crate::modules::annotate::table_cmd::TableCellEditCommand::new( + handle, table, row, column, + ) + }), + row * table.column_count() + column, + )) + } + _ => None, + }); + if let Some((command, cell_index)) = table_editor { + self.tabs[i].properties.prop_vertex = cell_index; + self.tabs[i].properties.prop_vertex_indicator_active = true; + crate::entities::table::set_prop_current_cell(cell_index); + self.refresh_properties(); + if let Some(command) = command { + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + return Task::none(); + } // Any text-bearing entity opens its in-place editor // (plain box or rich MText editor, per type). A // Leader resolves to the entity it annotates. @@ -4487,7 +4620,10 @@ impl OpenCADStudio { if let Some(AcadEntityType::Viewport(vp)) = self.tabs[i].scene.document.get_entity(h) { - if Scene::is_content_viewport(vp) { + if !Scene::is_sheet_viewport( + &self.tabs[i].scene.document, + vp, + ) { Some(h) } else { None @@ -5160,8 +5296,7 @@ impl OpenCADStudio { self.tabs[i].scene.set_current_layout(name.clone()); self.tabs[i].scene.deselect_all(); let switch_ms = perf_phase.elapsed().as_secs_f64() * 1000.0; - // UCS follows the pane: model header UCS in the Model tab, none - // in plain paper space (a viewport's UCS is adopted on entry). + // UCS follows the active model, layout, or floating viewport pane. let perf_phase = Instant::now(); self.tabs[i].refresh_active_ucs(); self.tabs[i].scene.restore_saved_camera(); @@ -5225,10 +5360,28 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "LAYOUT"); match self.tabs[i].scene.document.add_layout(&new_name) { Ok(_) => { - // Override the acadrust default limits (12×9 imperial) with A4 landscape. + let layout_flags = i16::from( + self.tabs[i] + .scene + .document + .header + .paper_space_linetype_scaling, + ) | (i16::from( + self.tabs[i] + .scene + .document + .header + .paper_space_limit_check, + ) << 1); + let plot_style = self + .active_plot_style + .as_ref() + .map(|style| style.name.clone()) + .unwrap_or_default(); for obj in self.tabs[i].scene.document.objects.values_mut() { if let acadrust::objects::ObjectType::Layout(l) = obj { if l.name == new_name { + l.flags = layout_flags; l.min_limits = (0.0, 0.0); l.max_limits = (297.0, 210.0); l.min_extents = (0.0, 0.0, 0.0); @@ -5238,6 +5391,14 @@ impl OpenCADStudio { l.plot_paper_units = 1; l.plot_scale_numerator = 1.0; l.plot_scale_denominator = 1.0; + l.plot_scale_type = 16; + l.plot_scale_factor = 1.0; + l.plot_type = 5; + l.plot_flags.use_standard_scale = true; + l.plot_flags.print_lineweights = true; + l.plot_flags.plot_plot_styles = !plot_style.is_empty(); + l.plot_flags.show_plot_styles = !plot_style.is_empty(); + l.plot_style_sheet = plot_style; l.paper_size = "ISO_A4_(297.00_x_210.00_MM)".into(); break; } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index bfbce428..a9c104fa 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -32,6 +32,8 @@ use viewcube::{viewcube_nav_controls, viewcube_ucs_picker, UCS_PICKER_W}; // the `view::` path as before the split. pub(in crate::app) use overlay::{MTEXT_TEXT_ID, TEXT_INLINE_ID}; +pub(in crate::app) const VIEWPORT_CAPTURE_BOUNDS_ID: &str = "viewport-capture-bounds"; + const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_REGION_PX; /// The desk shown around the sheet, as the widget wants it. One definition, /// shared with the renderer that clears the sheet viewport to the same thing. @@ -184,6 +186,7 @@ impl OpenCADStudio { pub fn view_main(&self) -> Element<'_, Message> { let i = self.active_tab; let tab = &self.tabs[i]; + let thumbnail_capture_clean = self.thumbnail_capture_clean; let theme_text = self.active_theme.palette().background.base.text; let viewcube_text_color = [ theme_text.r, @@ -229,8 +232,10 @@ impl OpenCADStudio { let (vw, vh) = tab.scene.selection.borrow().vp_size; tab.scene.active_model_tile_bounds(vw, vh).width }; - let viewcube_visible = - self.show_viewcube && !tab.is_start && viewcube_has_room(render_bar_w, active_vp_w); + let viewcube_visible = self.show_viewcube + && !thumbnail_capture_clean + && !tab.is_start + && viewcube_has_room(render_bar_w, active_vp_w); // Start tab: render welcome page in place of the viewport. // Surrounding chrome (tab bar, status bar) stays; the welcome widget // returned here also flags the rest of `view` to skip drawing-only @@ -258,6 +263,7 @@ impl OpenCADStudio { shader(ViewportPane::model( &tab.scene, viewcube_visible, + !thumbnail_capture_clean, viewport_render_mode, viewcube_text_color, )) @@ -276,6 +282,7 @@ impl OpenCADStudio { // mouse_areas' hover state and drops their move events). let scene = &tab.scene; let show_viewcube = viewcube_visible; + let show_interaction = !thumbnail_capture_clean; let render_mode = viewport_render_mode; let size_probe: Element<'_, Message> = responsive(move |size| { { @@ -293,6 +300,7 @@ impl OpenCADStudio { shader(ViewportPane::for_pane( scene, show_viewcube, + show_interaction, render_mode, idx, viewcube_text_color, @@ -868,6 +876,20 @@ impl OpenCADStudio { .height(Fill)] .width(Fill) .height(Fill) + } else if thumbnail_capture_clean { + let capture_bg = if is_paper { PAPER_SPACE_BACKGROUND } else { bg_color }; + stack![ + container(Space::new()) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(capture_bg)), + ..Default::default() + }) + .width(Fill) + .height(Fill), + viewport_3d, + ] + .width(Fill) + .height(Fill) } else if is_paper { // Keep the grid above the opaque GPU sheet and below interaction UI. stack![ @@ -902,6 +924,7 @@ impl OpenCADStudio { .height(Fill) }; + if !thumbnail_capture_clean { // Per-pane input pane_grid goes ABOVE the crosshair overlay so it // receives mouse events (the overlay's `Hidden` cursor would otherwise // starve any layer beneath it). The controls bar is pushed on top of it. @@ -1481,6 +1504,7 @@ impl OpenCADStudio { viewport_stack = viewport_stack.push(text_inline_overlay(ed, canvas)); } } + } // Docked side panels (Properties, block palette, future palettes) live // in an ordered vertical stack on the left/right edge of the drawing @@ -1535,7 +1559,13 @@ impl OpenCADStudio { if let Some(e) = left_edge { parts.push(e); } - parts.push(viewport_stack.into()); + parts.push( + crate::ui::wrap_bar::PosReport::new( + VIEWPORT_CAPTURE_BOUNDS_ID, + viewport_stack, + ) + .into(), + ); if let Some(e) = right_edge { parts.push(e); } @@ -1712,7 +1742,9 @@ impl OpenCADStudio { &self.history_content, self.win_size.1, ); - let center_stack: Element<'_, Message> = if tab.is_start { + let center_stack: Element<'_, Message> = if thumbnail_capture_clean { + workspace + } else if tab.is_start { column![ workspace, iced::widget::container(command_line) @@ -2096,6 +2128,11 @@ impl OpenCADStudio { } else { Subscription::none() }; + let thumbnail_capture = if self.thumbnail_capture_clean { + window::frames().map(|_| Message::ThumbnailCaptureFrame) + } else { + Subscription::none() + }; // Blink the MText preview caret while the editor is open. let caret_blink = if self.mtext_editor.is_some() { iced::time::every(std::time::Duration::from_millis(530)) @@ -2154,6 +2191,7 @@ impl OpenCADStudio { grip_dwell, hover_dwell, nav_settle, + thumbnail_capture, caret_blink, web_fonts, autosave, diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index a3ab99a0..4d4484ab 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -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"), @@ -238,6 +239,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( diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index 82e6f68b..c1c4dac3 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -6,7 +6,8 @@ use iced::advanced::renderer; use iced::advanced::widget; use iced::advanced::{Layout, Shell, Widget}; use iced::widget::{ - button, column, container, mouse_area, row, scrollable, stack, text, text_input, Space, + button, checkbox, column, container, mouse_area, row, scrollable, stack, text, text_input, + Space, }; use iced::{Background, Border, Color, Element, Event, Fill, Length, Rectangle, Size, Theme, Vector}; use crate::t; @@ -374,7 +375,7 @@ pub(super) fn mtext_editor_overlay<'a>( iced::widget::Space::new().width(Fill).height(Fill), t!("Text Editor"), content, - Message::MTextCancel, + Message::MTextOk, modal_offset, crate::ui::modal::ModalOptions::STANDARD, ) @@ -450,6 +451,9 @@ fn mtext_editor_content<'a>( .on_select(Message::MTextStyle) .text_size(11) .width(iced::Length::Fixed(96.0)); + let annotative = checkbox(ed.annotative) + .on_toggle(Message::MTextAnnotative) + .size(14); let font_sel = if ed.font.trim().is_empty() { "[Style default]".to_string() } else { @@ -474,6 +478,7 @@ fn mtext_editor_content<'a>( crate::ui::color_select::ColorExtras { by_layer: true, by_block: false, + ..Default::default() }, Message::MTextColorChanged, Message::MTextColorPickerToggle, @@ -486,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), @@ -534,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"), @@ -572,6 +588,144 @@ fn mtext_editor_content<'a>( .on_press(Message::MTextLineSpacing(2.0)) .padding(3) .style(btn_style), + button(lbl("Stack")) + .on_press(Message::MTextStack) + .padding(3) + .style(btn_style), + button(lbl("Clear")) + .on_press(Message::MTextClearFormatting) + .padding(3) + .style(btn_style), + button(lbl("°")) + .on_press(Message::MTextInsert("°".to_string())) + .padding(3) + .style(btn_style), + button(lbl("±")) + .on_press(Message::MTextInsert("±".to_string())) + .padding(3) + .style(btn_style), + button(lbl("⌀")) + .on_press(Message::MTextInsert("⌀".to_string())) + .padding(3) + .style(btn_style), + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .width(width); + + let column_mode = match (ed.column_type, ed.column_auto_height) { + (1, _) => "Static", + (2, true) => "Dynamic auto", + (2, false) => "Dynamic manual", + _ => "No columns", + } + .to_string(); + let column_picker = iced::widget::pick_list( + Some(column_mode), + ["No columns", "Static", "Dynamic auto", "Dynamic manual"] + .into_iter() + .map(str::to_string) + .collect::>(), + |value| value.to_string(), + ) + .on_select(Message::MTextColumnMode) + .text_size(11) + .width(iced::Length::Fixed(120.0)); + let reverse = checkbox(ed.column_flow_reversed) + .on_toggle(Message::MTextColumnFlowReversed) + .size(14); + let row3 = row![ + lbl("Columns"), + column_picker, + lbl("Count"), + small_input("2", &ed.column_count, Message::MTextColumnCount, 42.0), + lbl("Height"), + text_input("0", &ed.rect_height) + .on_input(Message::MTextColumnHeight) + .width(iced::Length::Fixed(58.0)) + .padding(3) + .size(12), + lbl("Width"), + small_input("0", &ed.column_width, Message::MTextColumnWidth, 58.0), + lbl("Gutter"), + small_input("0", &ed.column_gutter, Message::MTextColumnGutter, 58.0), + reverse, + lbl("Reverse"), + iced::widget::Space::new().width(8), + lbl("First"), + text_input("0", &ed.paragraph_first_indent) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::FirstIndent, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("Left"), + text_input("0", &ed.paragraph_left_indent) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::LeftIndent, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("Right"), + text_input("0", &ed.paragraph_right_indent) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::RightIndent, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("Before"), + text_input("0", &ed.paragraph_space_before) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::SpaceBefore, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("After"), + text_input("0", &ed.paragraph_space_after) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::SpaceAfter, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .width(width); + let row4 = row![ + lbl("Find"), + text_input("Find", &ed.find_text) + .on_input(Message::MTextFindText) + .width(iced::Length::Fixed(140.0)) + .padding(3) + .size(12), + button(lbl("Next")) + .on_press(Message::MTextFindNext) + .padding(3) + .style(btn_style), + lbl("Replace"), + text_input("Replace", &ed.replace_text) + .on_input(Message::MTextReplaceText) + .width(iced::Length::Fixed(140.0)) + .padding(3) + .size(12), + button(lbl("Replace")) + .on_press(Message::MTextReplaceNext) + .padding(3) + .style(btn_style), + button(lbl("Replace All")) + .on_press(Message::MTextReplaceAll) + .padding(3) + .style(btn_style), ] .spacing(4) .align_y(iced::Alignment::Center) @@ -709,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), ) @@ -729,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), diff --git a/src/command.rs b/src/command.rs index 39e086e4..d4a2312b 100644 --- a/src/command.rs +++ b/src/command.rs @@ -169,6 +169,38 @@ pub struct SelectionEntity { pub surface_area: Option, } +/// Association source with an optional sub-entity marker. +#[derive(Clone, Copy, Debug)] +pub struct DimensionAssociationSource { + pub handle: Handle, + pub marker: Option, + pub parameter: f64, +} + +impl DimensionAssociationSource { + pub const fn inferred(handle: Handle) -> Self { + Self { + handle, + marker: None, + parameter: 0.0, + } + } + + pub const fn explicit(handle: Handle, marker: i32, parameter: f64) -> Self { + Self { + handle, + marker: Some(marker), + parameter, + } + } +} + +#[derive(Clone, Debug)] +pub enum DimensionAssociationInput { + Infer(Option), + Explicit(Vec>), +} + #[derive(Clone)] pub enum AreaPreviewSource { Handles(Vec), @@ -542,6 +574,34 @@ impl KeywordCommand { } } +fn match_cmd_option<'a>( + options: &'a [(&'static str, &'static str, Option<&'static str>)], + text: &str, +) -> Option<&'a (&'static str, &'static str, Option<&'static str>)> { + let t = text.trim(); + let up = t.to_uppercase(); + if up.is_empty() { + return None; + } + // 1. Exact match on keyword or label (case-insensitive) + if let Some(opt) = options.iter().find(|(label, k, _)| { + k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t) + }) { + return Some(opt); + } + // 2. Unambiguous prefix match on keyword or label (e.g. "A" -> "ABOVE", "L" -> "LEFT") + let matches: Vec<_> = options + .iter() + .filter(|(label, k, _)| { + k.to_uppercase().starts_with(&up) || label.to_uppercase().starts_with(&up) + }) + .collect(); + if matches.len() == 1 { + return Some(matches[0]); + } + None +} + impl CadCommand for KeywordCommand { fn name(&self) -> &'static str { self.name @@ -581,10 +641,7 @@ impl CadCommand for KeywordCommand { // Consumed inputs that keep prompting return `Some(NeedPoint)` — // `None` would hand the same text to the command a second time. None => { - let up = t.to_uppercase(); - let Some((_, keyword, value_prompt)) = self.options.iter().find(|(label, k, _)| { - k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t) - }) + let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) else { // Unknown verb — keep prompting rather than dispatch garbage. return Some(CmdResult::NeedPoint); @@ -783,11 +840,18 @@ impl CadCommand for SelectThenKeywordCommand { return None; } match self.pending { - Some((keyword, _)) => Some(CmdResult::Dispatch(format!("{} {keyword} {t}", self.name))), + Some((keyword, _)) => { + if self.selected.is_empty() { + Some(CmdResult::Dispatch(format!("{} {keyword} {t}", self.name))) + } else { + Some(CmdResult::Relaunch( + format!("{} {keyword} {t}", self.name), + std::mem::take(&mut self.selected), + )) + } + } None => { - let up = t.to_uppercase(); - let Some((_, keyword, value_prompt)) = - self.options.iter().find(|(_, k, _)| k.eq_ignore_ascii_case(&up)) + let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) else { // Unknown verb — consumed, keep prompting (`None` would // feed the same text to the command a second time). @@ -798,7 +862,16 @@ impl CadCommand for SelectThenKeywordCommand { self.pending = Some((keyword, vp)); Some(CmdResult::NeedPoint) } - None => Some(CmdResult::Dispatch(format!("{} {keyword}", self.name))), + None => { + if self.selected.is_empty() { + Some(CmdResult::Dispatch(format!("{} {keyword}", self.name))) + } else { + Some(CmdResult::Relaunch( + format!("{} {keyword}", self.name), + std::mem::take(&mut self.selected), + )) + } + } } } } @@ -1171,6 +1244,11 @@ pub enum CmdResult { CommitEntitiesAndExit(Vec), /// Commit an acadrust entity to the document and end the command. CommitAndExit(EntityType), + /// Commit a dimension using the drawing's association mode. + CommitDimension { + entity: EntityType, + association: DimensionAssociationInput, + }, /// Commit a Model-tab 3D solid: the acadrust entity (for selection / /// persistence) plus its B-rep (cached for boolean ops + shaded /// rendering). Ends the command. @@ -1349,6 +1427,17 @@ pub enum CmdResult { handle: Option, initial: String, height: f64, + /// Optional entity defaults collected by the interactive MTEXT + /// command (boundary, rotation, attachment, spacing and columns). + /// Existing-entity edits leave this as `None` and load the document + /// entity instead. + template: Option>, + }, + /// Collect rich text without creating an MText entity. + SuspendForMTextInput { + pos: DVec3, + initial: String, + height: f64, }, /// Open the in-place single-line TEXT editor (a plain text-entry box, no /// formatting toolbar). `handle` is `Some` when editing an existing Text, @@ -1359,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, @@ -1384,17 +1481,20 @@ pub enum CmdResult { /// Translation vector applied once to every selected point. delta: DVec3, }, - /// Create a Solid3D placeholder entity + associated MeshModel. - /// `mesh_fn` is called with the entity's handle string to build the mesh. - CommitSolid3D { - mesh_fn: Box Option + Send>, - }, - /// Extrude the profile entity `handle` by `height` along Z. + /// Extrude the profile entity `handle` along its plane normal. ExtrudeEntity { handle: Handle, height: f64, color: [f32; 4], }, + /// Pull a closed profile or a planar solid face by a signed distance. + PresspullEntity { + handle: Handle, + pick: DVec3, + distance: f64, + drag: Option, + color: [f32; 4], + }, /// Revolve the profile entity `handle` around the given axis by `angle_deg`. RevolveEntity { handle: Handle, @@ -1414,6 +1514,17 @@ pub enum CmdResult { handles: Vec, color: [f32; 4], }, + /// Round or bevel the straight edge nearest `pick` on a solid. + SolidEdgeBlend { + handle: Handle, + pick: DVec3, + value: f64, + fillet: bool, + }, + SolidSubtract { + bases: Vec, + cutters: Vec, + }, /// INSERT landed on a block that has AttributeDefinitions. /// The host should look up the attdefs for `block_name` from the document /// and call `attreq_set_attdefs()` on the command, then loop on text input. @@ -1655,6 +1766,16 @@ pub trait CadCommand: Send { Vec::new() } + /// Live search: called on each keystroke in the command line while the + /// command is active. Return true if the input updated internal filter + /// and the UI should refresh (prompt/options). Used for INSERT/MINSERT + /// incremental block name search without requiring Enter. Performance + /// critical — implementations must use precomputed caches and partial + /// sorting. + fn on_live_input(&mut self, _input: &str) -> bool { + false + } + /// Push the active coordinate frame in full precision. Geometry commands /// use it for plane-local construction; inquiry and modify commands use it /// for local deltas, angles and transformation axes. @@ -1717,6 +1838,11 @@ pub trait CadCommand: Send { false } + /// Accept typed coordinates while object picking. + fn entity_pick_accepts_points(&self) -> bool { + false + } + /// Include filled hatch / DXF SOLID regions in the entity hit-test. /// /// Most entity-pick commands operate on curve geometry and intentionally @@ -1726,6 +1852,14 @@ pub trait CadCommand: Send { false } + /// Supply the mesh surface hit instead of the working-plane projection. + fn entity_pick_uses_surface_point(&self) -> bool { + false + } + + /// Supply the picked surface or profile direction when available. + fn set_entity_pick_direction(&mut self, _direction: Option) {} + /// Render the entity under the cursor through the normal rollover /// highlight while this command is waiting for an entity pick. fn entity_pick_highlights_hover(&self) -> bool { @@ -1737,6 +1871,11 @@ pub trait CadCommand: Send { CmdResult::Cancel } + /// 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 { @@ -1945,10 +2084,13 @@ pub trait CadCommand: Send { None } - /// Inject the block's attribute definitions for ATTREQ attr-filling after - /// the INSERT point is picked. Carries the full definitions so the created - /// attributes inherit their geometry, not just tag / prompt / default (#255). - fn attreq_set_attdefs(&mut self, _attdefs: Vec) {} + /// Inject block attribute definitions after the INSERT point is picked. + fn attreq_set_attdefs( + &mut self, + _attdefs: Vec, + ) -> Option { + None + } /// Returns the INSERT entity built so far (pending attr fill) if this is an /// ATTREQ-aware INSERT command waiting for attdef injection. diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index dd5cc539..626a2db4 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -200,6 +200,7 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument oblique_angle, is_backward: width_factor < 0.0, is_upside_down: false, + is_vertical: false, }; let layout = layout_mtext(&MTextRenderOpts { // Not an MTEXT: text in a fixed box, never columnar. @@ -213,6 +214,8 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument attach_h_anchor, v_anchor, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); @@ -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. diff --git a/src/entities/common.rs b/src/entities/common.rs index f8417b75..4c8c98f6 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -1,6 +1,8 @@ use std::cell::Cell; +use std::collections::HashSet; use crate::scene::model::object::{GripDef, GripShape, PropValue, Property}; +use crate::scene::model::wire_model::PatternStationPiece; /// Linear / angular unit format pulled from the document header so the /// per-thread properties pipeline can format values consistently without @@ -573,6 +575,33 @@ pub fn edit_scalar_prop(label: &str, field: &'static str, value: f64) -> Propert } } +/// Standard lineweight choices shared by entity-specific Properties rows. +pub fn lineweight_options() -> Vec { + let mut options = vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Default".to_string(), + ]; + for value in [ + 0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, + 140, 158, 200, 211, + ] { + options.push(format!("{:.2} mm", value as f64 / 100.0)); + } + options +} + +/// DIMLWD/DIMLWE lineweight value displayed by a Properties dropdown. +pub fn lineweight_label(value: i16) -> String { + match value { + -1 => "ByLayer".to_string(), + -2 => "ByBlock".to_string(), + -3 => "Default".to_string(), + value if value >= 0 => format!("{:.2} mm", value as f64 / 100.0), + _ => "Default".to_string(), + } +} + pub fn ro_prop(label: &str, field: &'static str, value: impl Into) -> Property { Property { label: label.into(), @@ -789,6 +818,158 @@ pub(crate) fn tapered_band_points( (pts, widths) } +pub(crate) struct WideBandOutline { + pub points: Vec<[f64; 3]>, + pub stations: Vec, + pub point_segments: Vec, + pub station_pieces: Vec, + pub source_length: f32, +} + +/// Builds the boundary of a variable-width band through the kernel. +pub(crate) fn wide_band_outline( + verts: &[([f64; 2], f64, f64, f64)], + is_closed: bool, + restart_per_segment: bool, + to_wcs: &dyn Fn(f64, f64) -> (f64, f64, f64), +) -> WideBandOutline { + let source = cadkernel::geom2d::Polyline { + vertices: verts + .iter() + .map(|(position, bulge, _, _)| cadkernel::geom2d::PolylineVertex { + position: *position, + bulge: *bulge, + }) + .collect(), + closed: is_closed, + }; + let segment_count = if is_closed { + verts.len() + } else { + verts.len().saturating_sub(1) + }; + let widths: Vec<[f64; 2]> = verts + .iter() + .take(segment_count) + .map(|(_, _, start, end)| [*start, *end]) + .collect(); + let boundary = cadkernel::geom2d::polyline_band_boundary( + &source, + &widths, + cadkernel::tessellation::DEFAULT_ANGLE, + ); + let mut points = Vec::new(); + let mut stations = Vec::new(); + let mut point_segments = Vec::new(); + for edge in boundary.edges { + if !points.is_empty() { + points.push([f64::NAN; 3]); + stations.push(0.0); + point_segments.push(-1); + } + points.extend(edge.points.into_iter().map(|point| { + let (x, y, z) = to_wcs(point[0], point[1]); + [x, y, z] + })); + stations.extend( + if restart_per_segment { + edge.segment_distances + } else { + edge.source_distances + } + .map(|distance| distance as f32), + ); + point_segments.extend([edge.source_segment as i32; 2]); + } + let station_pieces = boundary + .station_pieces + .into_iter() + .map(|piece| { + let start = to_wcs(piece.points[0][0], piece.points[0][1]); + let end = to_wcs(piece.points[1][0], piece.points[1][1]); + PatternStationPiece { + source_segment: piece.source_segment as u32, + source_distances: piece.source_distances.map(|distance| distance as f32), + segment_distances: piece.segment_distances.map(|distance| distance as f32), + vector: [ + (end.0 - start.0) as f32, + (end.1 - start.1) as f32, + (end.2 - start.2) as f32, + ], + } + }) + .collect(); + WideBandOutline { + points, + stations, + point_segments, + station_pieces, + source_length: boundary.source_length as f32, + } +} + +pub(crate) fn extrude_wide_band_outline( + outline: WideBandOutline, + extrusion: [f64; 3], +) -> WideBandOutline { + let base_points = outline.points; + let base_stations = outline.stations; + let base_segments = outline.point_segments; + let mut points = base_points.clone(); + let mut stations = base_stations.clone(); + let mut point_segments = base_segments.clone(); + points.push([f64::NAN; 3]); + stations.push(0.0); + point_segments.push(-1); + points.extend(base_points.iter().map(|point| { + if point[0].is_finite() { + [ + point[0] + extrusion[0], + point[1] + extrusion[1], + point[2] + extrusion[2], + ] + } else { + *point + } + })); + stations.extend_from_slice(&base_stations); + point_segments.extend_from_slice(&base_segments); + + let mut emitted = HashSet::new(); + for ((&point, &station), &segment) in base_points + .iter() + .zip(&base_stations) + .zip(&base_segments) + { + if !point[0].is_finite() { + continue; + } + let key = (point.map(f64::to_bits), station.to_bits(), segment); + if !emitted.insert(key) { + continue; + } + points.push([f64::NAN; 3]); + stations.push(0.0); + point_segments.push(-1); + points.push(point); + points.push([ + point[0] + extrusion[0], + point[1] + extrusion[1], + point[2] + extrusion[2], + ]); + stations.push(station); + stations.push(station); + point_segments.extend([segment; 2]); + } + WideBandOutline { + points, + stations, + point_segments, + station_pieces: outline.station_pieces, + source_length: outline.source_length, + } +} + /// Compute the filled boundary polygon for one polyline segment. /// For straight segments: a rectangle/trapezoid. /// For arc segments: an arc band (outer arc + reversed inner arc). diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs index 7535f143..893c6c42 100644 --- a/src/entities/dim_override.rs +++ b/src/entities/dim_override.rs @@ -9,7 +9,7 @@ use acadrust::types::Color; use acadrust::xdata::{ExtendedData, XDataValue}; -use acadrust::{CadDocument, Handle}; +use acadrust::{CadDocument, EntityType, Handle}; // DXF group codes of the dimension variables surfaced on the leader panel. pub const DIMSCALE: i16 = 40; // overall scale (real) @@ -77,7 +77,12 @@ pub const DIMATFIT: i16 = 289; pub const DIMFXLON: i16 = 290; pub const DIMTFILL: i16 = 69; pub const DIMTFILLCLR: i16 = 70; -pub const DIMTXTDIRECTION: i16 = 295; +pub const DIMTXTDIRECTION: i16 = 294; +pub const DIMALTMZF: i16 = 295; +pub const DIMALTMZS: i16 = 296; +pub const DIMMZF: i16 = 297; +pub const DIMMZS: i16 = 298; +pub const DIMTALN: i16 = 392; pub const DIMTXSTY: i16 = 340; pub const DIMBLK: i16 = 342; pub const DIMBLK1: i16 = 343; @@ -229,3 +234,526 @@ pub fn set(doc: &mut CadDocument, handle: Handle, code: i16, value: Option Option { + Some(match field { + "dim_arrow_size" => DIMASZ, + "dim_line_ext" => DIMDLE, + "dim_ext_line_ext" => DIMEXE, + "dim_ext_line_offset" => DIMEXO, + "dim_ext_line_fixed_length" => DIMFXL, + "dim_text_height" => DIMTXT, + "dim_text_offset" => DIMGAP, + "dim_scale_overall" => DIMSCALE, + "dim_roundoff" => DIMRND, + "dim_scale_linear" => DIMLFAC, + "dim_alt_scale_factor" => DIMALTF, + "dim_alt_sub_units_scale" => DIMALTMZF, + "dim_alt_roundoff" => DIMALTRND, + "dim_sub_units_scale" => DIMMZF, + "dim_tolerance_limit_lower" => DIMTM, + "dim_tolerance_limit_upper" => DIMTP, + "dim_tolerance_text_height" => DIMTFAC, + _ => return None, + }) +} + +pub fn property_int_code(field: &str) -> Option { + Some(match field { + "dim_line_lineweight" => DIMLWD, + "dim_ext_line_lineweight" => DIMLWE, + "dim_ext_line_fixed" => DIMFXLON, + "dim_text_pos_vert" => DIMTAD, + "dim_text_pos_hor" => DIMJUST, + "dim_text_outside_align" => DIMTOH, + "dim_text_inside_align" => DIMTIH, + "dim_fit" => DIMATFIT, + "dim_text_inside" => DIMTIX, + "dim_text_movement" => DIMTMOVE, + "dim_line_forced" => DIMTOFL, + "dim_line_inside" => DIMSOXD, + "dim_units" => DIMLUNIT, + "dim_precision" => DIMDEC, + "dim_angle_units" => DIMAUNIT, + "dim_angle_precision" => DIMADEC, + "dim_decimal_separator" => DIMDSEP, + "dim_fractional_type" => DIMFRAC, + "dim_text_view_direction" => DIMTXTDIRECTION, + "dim_alt_enabled" => DIMALT, + "dim_alt_format" => DIMALTU, + "dim_alt_precision" => DIMALTD, + "dim_alt_tolerance_precision" => DIMALTTD, + "dim_tolerance_precision" => DIMTDEC, + "dim_tolerance_pos_vert" => DIMTOLJ, + "dim_tolerance_alignment" => DIMTALN, + "dim_arc_symbol" => DIMARCSYM, + _ => return None, + }) +} + +pub fn property_string_code(field: &str) -> Option { + Some(match field { + "dim_sub_units_suffix" => DIMMZS, + "dim_alt_sub_units_suffix" => DIMALTMZS, + _ => return None, + }) +} + +fn yes(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "yes" | "on" | "true" | "1" => Some(1), + "no" | "off" | "false" | "0" => Some(0), + _ => None, + } +} + +fn inherited_int(doc: &CadDocument, handle: Handle, code: i16) -> i16 { + let Some(EntityType::Dimension(dimension)) = doc.get_entity(handle) else { + return 0; + }; + if let Some(value) = int(&dimension.base().common.extended_data, code) { + return value; + } + let Some(style) = doc.dim_styles.iter().find(|style| { + style.name.eq_ignore_ascii_case(&dimension.base().style_name) + || (dimension.base().style_name.trim().is_empty() + && style.name.eq_ignore_ascii_case("Standard")) + }) else { + return 0; + }; + match code { + DIMSD1 => style.dimsd1 as i16, + DIMSD2 => style.dimsd2 as i16, + DIMSE1 => style.dimse1 as i16, + DIMSE2 => style.dimse2 as i16, + DIMZIN => style.dimzin, + DIMALTZ => style.dimaltz, + DIMTZIN => style.dimtzin, + DIMALTTZ => style.dimalttz, + DIMTOL => style.dimtol as i16, + DIMLIM => style.dimlim as i16, + DIMALT => style.dimalt as i16, + DIMAZIN => style.dimazin, + DIMAUNIT => style.dimaunit, + DIMADEC => style.dimadec, + DIMTFILL => style.dimtfill, + DIMTALN => 0, + DIMARCSYM => style.dimarcsym, + _ => 0, + } +} + +fn inherited_real(doc: &CadDocument, handle: Handle, code: i16) -> f64 { + let Some(EntityType::Dimension(dimension)) = doc.get_entity(handle) else { + return 0.0; + }; + if let Some(value) = real(&dimension.base().common.extended_data, code) { + return value; + } + let Some(style) = doc.dim_styles.iter().find(|style| { + style.name.eq_ignore_ascii_case(&dimension.base().style_name) + || (dimension.base().style_name.trim().is_empty() + && style.name.eq_ignore_ascii_case("Standard")) + }) else { + return 0.0; + }; + match code { + DIMGAP => style.dimgap, + DIMCEN => style.dimcen, + DIMTP => style.dimtp, + DIMTM => style.dimtm, + _ => 0.0, + } +} + +fn inherited_string(doc: &CadDocument, handle: Handle, code: i16) -> String { + let Some(EntityType::Dimension(dimension)) = doc.get_entity(handle) else { + return String::new(); + }; + if let Some(value) = string(&dimension.base().common.extended_data, code) { + return value; + } + let Some(style) = doc.dim_styles.iter().find(|style| { + style.name.eq_ignore_ascii_case(&dimension.base().style_name) + || (dimension.base().style_name.trim().is_empty() + && style.name.eq_ignore_ascii_case("Standard")) + }) else { + return String::new(); + }; + match code { + DIMPOST => style.dimpost.clone(), + DIMAPOST => style.dimapost.clone(), + _ => String::new(), + } +} + +fn split_template(value: &str) -> (&str, &str) { + value.split_once("<>").unwrap_or(("", value)) +} + +pub fn set_property( + doc: &mut CadDocument, + handle: Handle, + field: &str, + value: &str, +) -> bool { + let trimmed = value.trim(); + if field == "dim_center_type" { + let current = inherited_real(doc, handle, DIMCEN); + let size = current.abs().max(0.01); + let value = match trimmed.to_ascii_lowercase().as_str() { + "none" => 0.0, + "center marks" => size, + "centerlines" => -size, + _ => return false, + }; + set(doc, handle, DIMCEN, Some(XDataValue::Real(value))); + return true; + } + if field == "dim_center_size" { + let Ok(size) = trimmed.parse::() else { + return false; + }; + if !size.is_finite() || size < 0.0 { + return false; + } + let current = inherited_real(doc, handle, DIMCEN); + let value = if current < 0.0 { -size } else { size }; + set(doc, handle, DIMCEN, Some(XDataValue::Real(value))); + return true; + } + let handle_field = match field { + "dim_arrowhead_1" => Some(DIMBLK1), + "dim_arrowhead_2" => Some(DIMBLK2), + "dim_linetype" => Some(DIMLTYPE), + "dim_ext_linetype_1" => Some(DIMLTEX1), + "dim_ext_linetype_2" => Some(DIMLTEX2), + "dim_text_style" => Some(DIMTXSTY), + _ => None, + }; + if let Some(code) = handle_field { + let resolved = match field { + "dim_arrowhead_1" | "dim_arrowhead_2" => { + if trimmed == "Closed filled" { + Some(Handle::NULL) + } else { + doc.block_records + .iter() + .find(|record| record.name == trimmed) + .map(|record| record.handle) + } + } + "dim_text_style" => doc + .text_styles + .iter() + .find(|style| style.name == trimmed) + .map(|style| style.handle), + _ => doc + .line_types + .iter() + .find(|line_type| line_type.name == trimmed) + .map(|line_type| line_type.handle), + }; + let Some(resolved) = resolved else { + return false; + }; + set(doc, handle, code, Some(XDataValue::Handle(resolved))); + return true; + } + let inverse_boolean_code = match field { + "dim_line_1" => Some(DIMSD1), + "dim_line_2" => Some(DIMSD2), + "dim_ext_line_1" => Some(DIMSE1), + "dim_ext_line_2" => Some(DIMSE2), + _ => None, + }; + if let Some(code) = inverse_boolean_code { + let Some(visible) = yes(trimmed) else { + return false; + }; + set(doc, handle, code, Some(XDataValue::Integer16((visible == 0) as i16))); + return true; + } + let bit_field = match field { + "dim_suppress_leading_zeros" => Some((DIMZIN, 4)), + "dim_suppress_trailing_zeros" => Some((DIMZIN, 8)), + "dim_alt_suppress_leading_zeros" => Some((DIMALTZ, 4)), + "dim_alt_suppress_trailing_zeros" => Some((DIMALTZ, 8)), + "dim_tolerance_suppress_leading_zeros" => Some((DIMTZIN, 4)), + "dim_tolerance_suppress_trailing_zeros" => Some((DIMTZIN, 8)), + "dim_alt_tolerance_suppress_leading_zeros" => Some((DIMALTTZ, 4)), + "dim_alt_tolerance_suppress_trailing_zeros" => Some((DIMALTTZ, 8)), + "dim_angle_suppress_leading_zeros" => Some((DIMAZIN, 1)), + "dim_angle_suppress_trailing_zeros" => Some((DIMAZIN, 2)), + _ => None, + }; + if let Some((code, bit)) = bit_field { + let Some(enabled) = yes(trimmed) else { + return false; + }; + let current = inherited_int(doc, handle, code); + let value = if enabled != 0 { + current | bit + } else { + current & !bit + }; + set(doc, handle, code, Some(XDataValue::Integer16(value))); + return true; + } + let zero_base = match field { + "dim_suppress_zero_feet" => Some((DIMZIN, true)), + "dim_suppress_zero_inches" => Some((DIMZIN, false)), + "dim_alt_suppress_zero_feet" => Some((DIMALTZ, true)), + "dim_alt_suppress_zero_inches" => Some((DIMALTZ, false)), + "dim_tolerance_suppress_zero_feet" => Some((DIMTZIN, true)), + "dim_tolerance_suppress_zero_inches" => Some((DIMTZIN, false)), + "dim_alt_tolerance_suppress_zero_feet" => Some((DIMALTTZ, true)), + "dim_alt_tolerance_suppress_zero_inches" => Some((DIMALTTZ, false)), + _ => None, + }; + if let Some((code, feet_field)) = zero_base { + let Some(enabled) = yes(trimmed).map(|value| value != 0) else { + return false; + }; + let current = inherited_int(doc, handle, code); + let mut suppress_feet = matches!(current & 3, 0 | 3); + let mut suppress_inches = matches!(current & 3, 0 | 2); + if feet_field { + suppress_feet = enabled; + } else { + suppress_inches = enabled; + } + let base = match (suppress_feet, suppress_inches) { + (true, true) => 0, + (false, false) => 1, + (false, true) => 2, + (true, false) => 3, + }; + set( + doc, + handle, + code, + Some(XDataValue::Integer16((current & !3) | base)), + ); + return true; + } + if field == "dim_text_fill_color" { + let mode = match trimmed.to_ascii_lowercase().as_str() { + "none" => 0, + "background" => 1, + "color" => 2, + _ => return false, + }; + set(doc, handle, DIMTFILL, Some(XDataValue::Integer16(mode))); + return true; + } + if matches!( + field, + "dim_prefix" | "dim_suffix" | "dim_alt_prefix" | "dim_alt_suffix" + ) { + let code = if field.starts_with("dim_alt_") { + DIMAPOST + } else { + DIMPOST + }; + let current = inherited_string(doc, handle, code); + let (old_prefix, old_suffix) = split_template(¤t); + let (prefix, suffix) = if field.ends_with("prefix") { + (value, old_suffix) + } else { + (old_prefix, value) + }; + set( + doc, + handle, + code, + Some(XDataValue::String(format!("{prefix}<>{suffix}"))), + ); + return true; + } + if field == "dim_tolerance_display" { + let current_gap = inherited_real(doc, handle, DIMGAP).abs(); + let upper = inherited_real(doc, handle, DIMTP); + let lower = inherited_real(doc, handle, DIMTM); + let (tolerance, limits, basic) = match trimmed.to_ascii_lowercase().as_str() { + "symmetrical" => { + set(doc, handle, DIMTM, Some(XDataValue::Real(upper))); + (1, 0, false) + } + "deviation" => { + if (upper - lower).abs() <= 1e-12 { + let distinct_lower = if upper.abs() > 1e-12 { 0.0 } else { 0.0001 }; + set( + doc, + handle, + DIMTM, + Some(XDataValue::Real(distinct_lower)), + ); + } + (1, 0, false) + } + "limits" => (0, 1, false), + "basic" => (0, 0, true), + "none" => (0, 0, false), + _ => return false, + }; + set(doc, handle, DIMTOL, Some(XDataValue::Integer16(tolerance))); + set(doc, handle, DIMLIM, Some(XDataValue::Integer16(limits))); + let gap = if basic { + -current_gap.max(0.0001) + } else { + current_gap + }; + set(doc, handle, DIMGAP, Some(XDataValue::Real(gap))); + return true; + } + if let Some(code) = property_real_code(field) { + if trimmed.is_empty() { + set(doc, handle, code, None); + } else if let Ok(number) = trimmed.parse::() { + let number = if field == "dim_text_offset" + && inherited_real(doc, handle, DIMGAP) < 0.0 + { + -number.abs() + } else { + number + }; + set(doc, handle, code, Some(XDataValue::Real(number))); + } else { + return false; + } + return true; + } + if let Some(code) = property_string_code(field) { + set( + doc, + handle, + code, + (!trimmed.is_empty()).then(|| XDataValue::String(value.to_string())), + ); + return true; + } + if let Some(code) = property_int_code(field) { + let parsed = match field { + "dim_line_lineweight" | "dim_ext_line_lineweight" => { + parse_lineweight_label(trimmed) + } + "dim_ext_line_fixed" + | "dim_text_outside_align" + | "dim_text_inside_align" + | "dim_text_inside" + | "dim_line_forced" + | "dim_line_inside" + | "dim_alt_enabled" => yes(trimmed), + "dim_decimal_separator" => trimmed + .chars() + .next() + .map(|character| character as i16), + "dim_units" => match trimmed.to_ascii_lowercase().as_str() { + "scientific" => Some(1), + "decimal" => Some(2), + "engineering" => Some(3), + "architectural" => Some(4), + "fractional" => Some(5), + "desktop" => Some(6), + _ => trimmed.parse().ok(), + }, + "dim_angle_units" => match trimmed.to_ascii_lowercase().as_str() { + "decimal degrees" => Some(0), + "degrees/minutes/seconds" => Some(1), + "gradians" => Some(2), + "radians" => Some(3), + _ => trimmed.parse().ok(), + }, + "dim_fractional_type" => match trimmed.to_ascii_lowercase().as_str() { + "horizontal" => Some(0), + "diagonal" => Some(1), + "not stacked" => Some(2), + _ => trimmed.parse().ok(), + }, + "dim_text_view_direction" => match trimmed.to_ascii_lowercase().as_str() { + "left-to-right" => Some(0), + "right-to-left" => Some(1), + _ => trimmed.parse().ok(), + }, + "dim_text_pos_vert" => match trimmed.to_ascii_lowercase().as_str() { + "centered" => Some(0), + "above" => Some(1), + "outside" => Some(2), + "jis" => Some(3), + "below" => Some(4), + _ => trimmed.parse().ok(), + }, + "dim_text_pos_hor" => match trimmed.to_ascii_lowercase().as_str() { + "centered" => Some(0), + "at extension line 1" => Some(1), + "at extension line 2" => Some(2), + "over extension line 1" => Some(3), + "over extension line 2" => Some(4), + _ => trimmed.parse().ok(), + }, + "dim_fit" => match trimmed.to_ascii_lowercase().as_str() { + "both text and arrows" => Some(0), + "arrows" => Some(1), + "text" => Some(2), + "best fit" => Some(3), + _ => trimmed.parse().ok(), + }, + "dim_text_movement" => match trimmed.to_ascii_lowercase().as_str() { + "keep dim line with text" => Some(0), + "move text, add leader" => Some(1), + "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), + "engineering" => Some(3), + "architectural stacked" => Some(4), + "fractional stacked" => Some(5), + "architectural" => Some(6), + "fractional" => Some(7), + "desktop" => Some(8), + _ => trimmed.parse().ok(), + }, + "dim_tolerance_pos_vert" => match trimmed.to_ascii_lowercase().as_str() { + "bottom" => Some(0), + "middle" => Some(1), + "top" => Some(2), + _ => trimmed.parse().ok(), + }, + "dim_tolerance_alignment" => match trimmed.to_ascii_lowercase().as_str() { + "align decimal separators" => Some(0), + "align operational symbols" => Some(1), + _ => trimmed.parse().ok(), + }, + _ => trimmed.parse().ok(), + }; + let Some(number) = parsed else { + return false; + }; + set(doc, handle, code, Some(XDataValue::Integer16(number))); + return true; + } + false +} + +fn parse_lineweight_label(value: &str) -> Option { + match value.trim() { + "ByLayer" => Some(-1), + "ByBlock" => Some(-2), + "Default" => Some(-3), + value => value + .trim_end_matches("mm") + .trim() + .parse::() + .ok() + .map(|millimetres| (millimetres * 100.0).round() as i16), + } +} diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index adda3e8f..061dcaa7 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -8,12 +8,38 @@ use glam::{DVec3, Vec3}; use crate::command::EntityTransform; use crate::entities::common::{ - center_grip, edit_angle_prop as edit_angle, edit_prop as edit, parse_f64, ro_prop as ro, square_grip, + center_grip, edit_angle_prop as edit_angle, edit_prop as edit, lineweight_label, + lineweight_options, parse_f64, ro_prop as ro, square_grip, }; use crate::entities::traits::{Grippable, PropertyEditable, Transformable}; -use crate::scene::model::object::{GripApply, GripDef, PropSection}; +use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; use crate::t; +pub(crate) fn dimension_text_override(base: &DimensionBase) -> Option<&str> { + base.user_text + .as_deref() + .or_else(|| (!base.text.is_empty()).then_some(base.text.as_str())) +} + +pub(crate) fn set_dimension_text_override(base: &mut DimensionBase, text: Option) { + base.text = text.clone().unwrap_or_default(); + base.user_text = text; +} + +fn dimension_definition_point(dim: &Dimension) -> acadrust::types::Vector3 { + match dim { + Dimension::Aligned(d) => d.definition_point, + Dimension::Linear(d) => d.definition_point, + Dimension::Radius(d) => d.definition_point, + Dimension::Diameter(d) => d.definition_point, + Dimension::Angular2Ln(d) => d.definition_point, + Dimension::Angular3Pt(d) => d.definition_point, + Dimension::Ordinate(d) => d.definition_point, + Dimension::Arc(d) => d.definition_point, + Dimension::LargeRadial(d) => d.definition_point, + } +} + fn base_props(base: &DimensionBase) -> Vec { vec![ crate::scene::model::object::Property { @@ -60,6 +86,151 @@ fn base_props(base: &DimensionBase) -> Vec Vec { + let compact_radial = match dim { + Dimension::Radius(radius) => Some((&radius.base, radius.leader_length)), + Dimension::Diameter(diameter) => Some((&diameter.base, diameter.leader_length)), + _ => None, + }; + if let Some((base, leader_length)) = compact_radial { + return vec![PropSection { + title: t!("Misc").into_owned(), + props: vec![ + Property { + label: t!("Dimension style").into_owned(), + field: "style_name", + value: PropValue::PlainText(base.style_name.clone()), + }, + edit( + t!("Leader Length").as_ref(), + "leader_length", + leader_length, + ), + ], + }]; + } + if let Dimension::LargeRadial(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()), + }, + edit(t!("Center X").as_ref(), "definition_x", d.definition_point.x), + edit(t!("Center Y").as_ref(), "definition_y", d.definition_point.y), + edit(t!("Center Z").as_ref(), "definition_z", d.definition_point.z), + edit(t!("Chord X").as_ref(), "chord_x", d.chord_point.x), + edit(t!("Chord Y").as_ref(), "chord_y", d.chord_point.y), + edit(t!("Chord Z").as_ref(), "chord_z", d.chord_point.z), + edit( + t!("Override Center X").as_ref(), + "override_x", + d.override_center.x, + ), + edit( + t!("Override Center Y").as_ref(), + "override_y", + d.override_center.y, + ), + edit( + t!("Override Center Z").as_ref(), + "override_z", + d.override_center.z, + ), + edit(t!("Jog X").as_ref(), "jog_x", d.jog_point.x), + edit(t!("Jog Y").as_ref(), "jog_y", d.jog_point.y), + edit(t!("Jog Z").as_ref(), "jog_z", d.jog_point.z), + edit_angle( + t!("Jog Angle").as_ref(), + "jog_angle", + d.jog_angle.to_degrees(), + ), + ro( + t!("Measurement").as_ref(), + "measurement", + format!("{:.4}", d.measurement()), + ), + ], + }]; + } + let compact_linear = match dim { + Dimension::Linear(d) => Some(( + &d.base, + d.rotation, + d.ext_line_rotation, + )), + Dimension::Aligned(d) => Some(( + &d.base, + (d.second_point.y - d.first_point.y) + .atan2(d.second_point.x - d.first_point.x), + d.ext_line_rotation, + )), + _ => None, + }; + if let Some((base, rotation, ext_line_rotation)) = compact_linear { + return vec![PropSection { + title: t!("Misc").into_owned(), + props: vec![ + crate::scene::model::object::Property { + label: t!("Dimension style").into_owned(), + field: "style_name", + value: crate::scene::model::object::PropValue::PlainText( + base.style_name.clone(), + ), + }, + edit_angle( + t!("Dim line angle").as_ref(), + "rotation", + rotation.to_degrees(), + ), + edit_angle( + t!("Extension line angle").as_ref(), + "ext_line_rotation", + ext_line_rotation.to_degrees(), + ), + ], + }]; + } + if matches!(dim, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) { + return vec![PropSection { + title: t!("Misc").into_owned(), + props: vec![crate::scene::model::object::Property { + label: t!("Dimension style").into_owned(), + field: "style_name", + value: crate::scene::model::object::PropValue::PlainText( + dim.base().style_name.clone(), + ), + }], + }]; + } + if let Dimension::Ordinate(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()), + }, + edit_angle( + t!("Rotation").as_ref(), + "ordinate_rotation", + -d.base.horizontal_direction.to_degrees(), + ), + ], + }]; + } + 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) => { @@ -156,21 +327,7 @@ fn properties(dim: &Dimension) -> Vec { props.push(edit(t!("Leader 2 Y").as_ref(), "leader2_y", d.second_leader_point.y)); props.push(edit(t!("Leader 2 Z").as_ref(), "leader2_z", d.second_leader_point.z)); } - Dimension::LargeRadial(d) => { - props.push(edit(t!("Center X").as_ref(), "definition_x", d.definition_point.x)); - props.push(edit(t!("Center Y").as_ref(), "definition_y", d.definition_point.y)); - props.push(edit(t!("Center Z").as_ref(), "definition_z", d.definition_point.z)); - props.push(edit(t!("Chord X").as_ref(), "chord_x", d.chord_point.x)); - props.push(edit(t!("Chord Y").as_ref(), "chord_y", d.chord_point.y)); - props.push(edit(t!("Chord Z").as_ref(), "chord_z", d.chord_point.z)); - props.push(edit(t!("Override Center X").as_ref(), "override_x", d.override_center.x)); - props.push(edit(t!("Override Center Y").as_ref(), "override_y", d.override_center.y)); - props.push(edit(t!("Override Center Z").as_ref(), "override_z", d.override_center.z)); - props.push(edit(t!("Jog X").as_ref(), "jog_x", d.jog_point.x)); - props.push(edit(t!("Jog Y").as_ref(), "jog_y", d.jog_point.y)); - props.push(edit(t!("Jog Z").as_ref(), "jog_z", d.jog_point.z)); - props.push(edit_angle(t!("Jog Angle").as_ref(), "jog_angle", d.jog_angle.to_degrees())); - } + Dimension::LargeRadial(_) => unreachable!("handled by compact large-radial properties"), } vec![PropSection { title: t!("Geometry").into_owned(), @@ -234,16 +391,12 @@ fn angular_props( fn apply_base_prop(base: &mut DimensionBase, field: &str, value: &str) -> bool { match field { - "text" => { - base.text = value.to_string(); - true - } - "user_text" => { - base.user_text = if value.trim().is_empty() { + "text" | "user_text" | "text_override" => { + set_dimension_text_override(base, if value.trim().is_empty() { None } else { Some(value.to_string()) - }; + }); true } "style_name" => { @@ -291,6 +444,40 @@ fn assign_deg(value: &str, target: &mut f64) -> bool { } fn apply_geom_prop(dim: &mut Dimension, field: &str, value: &str) { + let ordinate_auto_text = match dim { + Dimension::Ordinate(ordinate) if !ordinate.base.text_user_positioned => { + Some(dimension_text_pos_f64(dim, None, 2.5, 1.0)) + } + _ => None, + }; + if let Dimension::Ordinate(ordinate) = dim { + match field { + "ordinate_rotation" => { + if let Some(angle) = parse_f64(value) { + ordinate.base.horizontal_direction = -angle.to_radians(); + ordinate.refresh_measurement(); + } + return; + } + "text_x" | "text_y" => { + let old_text = ordinate_auto_text.unwrap_or(ordinate.base.text_middle_point); + let mut new_text = old_text; + let changed = if field == "text_x" { + assign_f64(value, &mut new_text.x) + } else { + assign_f64(value, &mut new_text.y) + }; + if changed { + let delta = new_text - old_text; + ordinate.leader_endpoint = ordinate.leader_endpoint + delta; + ordinate.base.text_middle_point = new_text; + ordinate.base.text_user_positioned = true; + } + return; + } + _ => {} + } + } if apply_base_prop(dim.base_mut(), field, value) { return; } @@ -305,10 +492,35 @@ fn apply_geom_prop(dim: &mut Dimension, field: &str, value: &str) { Dimension::Arc(d) => apply_arc_fields(d, field, value), Dimension::LargeRadial(d) => apply_large_radial_fields(d, field, value), } + let definition_point = dimension_definition_point(dim); + dim.base_mut().definition_point = definition_point; dim.base_mut().actual_measurement = dim.measurement(); } fn apply_linear_fields_aligned(d: &mut DimensionAligned, field: &str, value: &str) { + if field == "rotation" { + let Some(angle) = parse_f64(value).map(f64::to_radians) else { + return; + }; + let old_angle = (d.second_point.y - d.first_point.y) + .atan2(d.second_point.x - d.first_point.x); + let delta = angle - old_angle; + let origin_x = d.first_point.x; + let origin_y = d.first_point.y; + let rotate = |point: &mut acadrust::types::Vector3| { + let x = point.x - origin_x; + let y = point.y - origin_y; + let (sin, cos) = delta.sin_cos(); + point.x = origin_x + x * cos - y * sin; + point.y = origin_y + x * sin + y * cos; + }; + rotate(&mut d.second_point); + rotate(&mut d.definition_point); + rotate(&mut d.base.definition_point); + rotate(&mut d.base.text_middle_point); + rotate(&mut d.base.insertion_point); + return; + } apply_linear_common( &mut d.first_point, &mut d.second_point, @@ -624,6 +836,42 @@ fn apply_large_radial_fields(d: &mut DimensionLargeRadial, field: &str, value: & } fn apply_transform(dim: &mut Dimension, t: &EntityTransform) { + if matches!(dim, Dimension::Ordinate(_)) { + match t { + EntityTransform::Translate(delta) => dim.translate(Vector3::new( + delta.x, delta.y, delta.z, + )), + EntityTransform::Rotate { + center, + axis, + angle_rad, + } => crate::scene::view::transform::apply_standard_transform( + dim, + *center, + *axis, + *angle_rad, + ), + EntityTransform::Scale { center, factor } => { + crate::scene::view::transform::apply_standard_scale(dim, *center, *factor) + } + EntityTransform::Mirror { + p1, + p2, + working_normal, + } => acadrust::Entity::apply_transform( + dim, + &crate::scene::view::transform::reflection_about_working_line( + *p1, + *p2, + *working_normal, + ), + ), + EntityTransform::Affine(transform) => { + acadrust::Entity::apply_transform(dim, transform) + } + } + return; + } match t { EntityTransform::Translate(d) => dim.translate(acadrust::types::Vector3::new( d.x as f64, d.y as f64, d.z as f64, @@ -979,6 +1227,12 @@ fn restore_dim_text_relative_position( } fn dimension_line_grip_position(dim: &Dimension) -> Option { + match dim { + Dimension::Angular2Ln(d) => return Some(dv3(&d.dimension_arc)), + Dimension::Angular3Pt(d) => return Some(dv3(&d.definition_point)), + Dimension::Arc(d) => return Some(dv3(&d.definition_point)), + _ => {} + } let (first, second, defpt, ax, ay) = match dim { Dimension::Linear(d) => ( d.first_point, @@ -1031,6 +1285,32 @@ fn dimension_line_grip_position(dim: &Dimension) -> Option { Some((p1 + p2) * 0.5) } +fn above_dimension_text_position(dim: &Dimension) -> Option { + if let Some((vertex, start, end, radius)) = angular_dimension_frame(dim) { + let angle = (start + end) * 0.5; + let direction = DVec3::new(angle.cos() as f64, angle.sin() as f64, 0.0); + return Some( + DVec3::new(vertex.x as f64, vertex.y as f64, vertex.z as f64) + + direction * (radius as f64 + 1.0), + ); + } + let center = dimension_line_grip_position(dim)?; + let (ax, ay) = match dim { + Dimension::Linear(d) => (d.rotation.cos(), d.rotation.sin()), + Dimension::Aligned(d) => { + let dx = d.second_point.x - d.first_point.x; + let dy = d.second_point.y - d.first_point.y; + let length = (dx * dx + dy * dy).sqrt(); + if length <= 1e-12 { + return None; + } + (dx / length, dy / length) + } + _ => return Some(center + DVec3::Y), + }; + Some(center + DVec3::new(-ay, ax, 0.0)) +} + impl Grippable for Dimension { fn grips(&self) -> Vec { // Auto-placed dimensions carry a zero text_middle_point sentinel; put @@ -1076,11 +1356,12 @@ impl Grippable for Dimension { center_grip(2, text), ], Dimension::Angular2Ln(d) => vec![ - square_grip(0, dv3(&d.angle_vertex)), - center_grip(1, dv3(&d.first_point)), - center_grip(2, dv3(&d.second_point)), + square_grip(0, dv3(&d.first_point)), + center_grip(1, dv3(&d.second_point)), + center_grip(2, dv3(&d.angle_vertex)), center_grip(3, dv3(&d.definition_point)), - center_grip(4, text), + center_grip(4, dv3(&d.dimension_arc)), + center_grip(5, text), ], Dimension::Angular3Pt(d) => vec![ square_grip(0, dv3(&d.angle_vertex)), @@ -1090,10 +1371,9 @@ impl Grippable for Dimension { center_grip(4, text), ], Dimension::Ordinate(d) => vec![ - square_grip(0, dv3(&d.definition_point)), - center_grip(1, dv3(&d.feature_location)), - center_grip(2, dv3(&d.leader_endpoint)), - center_grip(3, text), + square_grip(0, dv3(&d.feature_location)), + center_grip(1, dv3(&d.leader_endpoint)), + center_grip(2, text), ], Dimension::Arc(d) => { let mut grips = vec![ @@ -1124,12 +1404,26 @@ impl Grippable for Dimension { fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { + if matches!(self, Dimension::Ordinate(_)) && grip_id == 2 { + let old_text = dimension_text_pos_f64(self, None, 2.5, 1.0); + let mut new_text = old_text; + apply_to_v3(&mut new_text, &apply); + let delta = new_text - old_text; + if let Dimension::Ordinate(d) = self { + d.leader_endpoint = d.leader_endpoint + delta; + d.base.text_middle_point = new_text; + d.base.text_user_positioned = true; + d.refresh_measurement(); + } + return; + } // Last grip always moves the text. let text_grip = match self { Dimension::Linear(_) | Dimension::Aligned(_) => 3, Dimension::Radius(_) | Dimension::Diameter(_) => 2, - Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => 4, - Dimension::Ordinate(_) => 3, + Dimension::Angular2Ln(_) => 5, + Dimension::Angular3Pt(_) => 4, + Dimension::Ordinate(_) => 2, Dimension::Arc(d) => if d.has_leader { 6 } else { 4 }, Dimension::LargeRadial(_) => 4, }; @@ -1165,16 +1459,35 @@ impl Grippable for Dimension { 1 => apply_to_v3(&mut d.definition_point, &apply), _ => {} }, - Dimension::Diameter(d) => match grip_id { - 0 => apply_to_v3(&mut d.angle_vertex, &apply), - 1 => apply_to_v3(&mut d.definition_point, &apply), - _ => {} - }, + Dimension::Diameter(d) => { + let center = d.center(); + let radius = d.measurement() * 0.5; + let mut target = match grip_id { + 0 => d.angle_vertex, + 1 => d.definition_point, + _ => center, + }; + if grip_id <= 1 { + apply_to_v3(&mut target, &apply); + let offset = target - center; + if offset.length_squared() > 1e-24 && radius > 1e-12 { + let radial = offset.normalize() * radius; + if grip_id == 0 { + d.angle_vertex = center + radial; + d.definition_point = center - radial; + } else { + d.definition_point = center + radial; + d.angle_vertex = center - radial; + } + } + } + } Dimension::Angular2Ln(d) => match grip_id { - 0 => apply_to_v3(&mut d.angle_vertex, &apply), - 1 => apply_to_v3(&mut d.first_point, &apply), - 2 => apply_to_v3(&mut d.second_point, &apply), + 0 => apply_to_v3(&mut d.first_point, &apply), + 1 => apply_to_v3(&mut d.second_point, &apply), + 2 => apply_to_v3(&mut d.angle_vertex, &apply), 3 => apply_to_v3(&mut d.definition_point, &apply), + 4 => apply_to_v3(&mut d.dimension_arc, &apply), _ => {} }, Dimension::Angular3Pt(d) => match grip_id { @@ -1185,9 +1498,15 @@ impl Grippable for Dimension { _ => {} }, Dimension::Ordinate(d) => match grip_id { - 0 => apply_to_v3(&mut d.definition_point, &apply), - 1 => apply_to_v3(&mut d.feature_location, &apply), - 2 => apply_to_v3(&mut d.leader_endpoint, &apply), + 0 => apply_to_v3(&mut d.feature_location, &apply), + 1 => { + let old = d.leader_endpoint; + apply_to_v3(&mut d.leader_endpoint, &apply); + if d.base.text_user_positioned { + d.base.text_middle_point = + d.base.text_middle_point + (d.leader_endpoint - old); + } + } _ => {} }, Dimension::Arc(d) => match grip_id { @@ -1212,6 +1531,8 @@ impl Grippable for Dimension { restore_dim_text_relative_position(self, saved); } + let definition_point = dimension_definition_point(self); + self.base_mut().definition_point = definition_point; self.base_mut().actual_measurement = self.measurement(); } @@ -1220,8 +1541,9 @@ impl Grippable for Dimension { let (dim_line_grip, text_grip) = match self { Dimension::Linear(_) | Dimension::Aligned(_) => (2, 3), Dimension::Radius(_) | Dimension::Diameter(_) => (1, 2), - Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => (3, 4), - Dimension::Ordinate(_) => (0, 3), + Dimension::Angular2Ln(_) => (4, 5), + Dimension::Angular3Pt(_) => (3, 4), + Dimension::Ordinate(_) => (1, 2), Dimension::Arc(d) => (3, if d.has_leader { 6 } else { 4 }), Dimension::LargeRadial(_) => (3, 4), }; @@ -1260,6 +1582,11 @@ impl Grippable for Dimension { action: GripMenuAction::Center, }, ] + } else if matches!(self, Dimension::Ordinate(_)) { + vec![GripMenuItem { + label: "Stretch", + action: GripMenuAction::Stretch, + }] } else if grip_id == dim_line_grip { vec![ GripMenuItem { @@ -1288,8 +1615,9 @@ impl Grippable for Dimension { let (_dim_line_grip, text_grip) = match self { Dimension::Linear(_) | Dimension::Aligned(_) => (2, 3), Dimension::Radius(_) | Dimension::Diameter(_) => (1, 2), - Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => (3, 4), - Dimension::Ordinate(_) => (0, 3), + Dimension::Angular2Ln(_) => (4, 5), + Dimension::Angular3Pt(_) => (3, 4), + Dimension::Ordinate(_) => (1, 2), Dimension::Arc(d) => (3, if d.has_leader { 6 } else { 4 }), Dimension::LargeRadial(_) => (3, 4), }; @@ -1301,33 +1629,63 @@ impl Grippable for Dimension { b.text_middle_point.x = 0.0; b.text_middle_point.y = 0.0; b.text_middle_point.z = 0.0; + b.text_user_positioned = false; } A::Center if grip_id == text_grip => { - // Snap text to the centre of the dimension line. - // Approximate as midpoint of first/second extension - // origins for Linear / Aligned dimensions. - match self { - Dimension::Linear(d) => { - let mx = (d.first_point.x + d.second_point.x) * 0.5; - let my = (d.first_point.y + d.second_point.y) * 0.5; - d.base.text_middle_point.x = mx; - d.base.text_middle_point.y = my; - } - Dimension::Aligned(d) => { - let mx = (d.first_point.x + d.second_point.x) * 0.5; - let my = (d.first_point.y + d.second_point.y) * 0.5; - d.base.text_middle_point.x = mx; - d.base.text_middle_point.y = my; - } - _ => {} + if let Some(point) = dimension_line_grip_position(self) { + let base = self.base_mut(); + base.text_middle_point = Vector3::new(point.x, point.y, point.z); + base.text_user_positioned = true; + } + } + A::ReverseArrows => { + let base = self.base_mut(); + base.flip_arrow1 = !base.flip_arrow1; + base.flip_arrow2 = !base.flip_arrow2; + } + A::AboveDimLine if grip_id == text_grip => { + if let Some(point) = above_dimension_text_position(self) { + let base = self.base_mut(); + base.text_middle_point = Vector3::new(point.x, point.y + 1.0, point.z); + base.text_user_positioned = true; } } - // Stretch / Move-variants / Reverse Arrows / Rotate Text / - // Above Dim Line need either a follow-up drag or a numeric - // prompt — wired to default Stretch behaviour for now. _ => {} } } + + fn grip_menu_value_prompt( + &self, + grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + ) -> Option<&'static str> { + use crate::scene::model::object::GripMenuAction as A; + let text_grip = match self { + Dimension::Linear(_) | Dimension::Aligned(_) => 3, + Dimension::Radius(_) | Dimension::Diameter(_) => 2, + Dimension::Angular2Ln(_) => 5, + Dimension::Angular3Pt(_) => 4, + Dimension::Ordinate(_) => 2, + Dimension::Arc(d) => if d.has_leader { 6 } else { 4 }, + Dimension::LargeRadial(_) => 4, + }; + (grip_id == text_grip && matches!(action, A::RotateText)) + .then_some("Specify text rotation") + } + + fn apply_grip_menu_value( + &mut self, + grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + value: f64, + ) { + use crate::scene::model::object::GripMenuAction as A; + if self.grip_menu_value_prompt(grip_id, action).is_some() + && matches!(action, A::RotateText) + { + self.base_mut().text_rotation = value.to_radians(); + } + } } // ── Tessellation ───────────────────────────────────────────────────────── @@ -1343,167 +1701,1254 @@ use acadrust::entities::{MText, Text}; use acadrust::tables::DimStyle; use acadrust::types::{Color as AcadColor, Vector3}; -/// Dimension-style-derived property groups (Lines & Arrows, Text, Fit, Primary -/// Units, Alternate Units, Tolerances) built from the resolved DimStyle. These -/// are read-only mirrors of the style's dimension variables, injected by the -/// panel after the entity's own Misc/Geometry groups. -pub fn style_sections(style: &DimStyle) -> Vec { - let s = style; - let yn = |b: bool| if b { "Yes" } else { "No" }; - let onoff = |b: bool| if b { "On" } else { "Off" }; - let f = |v: f64| format!("{v:.4}"); - let dsep = { - let c = s.dimdsep as u8 as char; - if s.dimdsep > 0 && !c.is_control() { - c.to_string() - } else { - s.dimdsep.to_string() - } +/// Build the linear-dimension property groups from the assigned style plus +/// any entity-level DSTYLE overrides. Conditional rows remain visible but are +/// read-only while their controlling option is disabled. +pub fn style_sections( + style: &DimStyle, + dimension: &Dimension, + document: &CadDocument, +) -> Vec { + use crate::entities::dim_override as ov; + + let data = &dimension.base().common.extended_data; + let real = |code, inherited| ov::real(data, code).unwrap_or(inherited); + let int = |code, inherited| ov::int(data, code).unwrap_or(inherited); + let string = |code, inherited: &str| { + ov::string(data, code).unwrap_or_else(|| inherited.to_string()) }; - let tol_display = if s.dimlim { + let on = |value: bool| if value { "On" } else { "Off" }; + let yes = |value: bool| if value { "Yes" } else { "No" }; + let number = |label: &str, field: &'static str, value: f64, editable: bool| { + property( + label, + field, + if editable { + PropValue::EditText(format!("{value:.4}")) + } else { + PropValue::ReadOnly(format!("{value:.4}")) + }, + ) + }; + let text = |label: &str, field: &'static str, value: String, editable: bool| { + property( + label, + field, + if editable { + PropValue::PlainText(value) + } else { + PropValue::ReadOnly(value) + }, + ) + }; + let choice = |label: &str, + field: &'static str, + selected: &str, + options: &[&str], + editable: bool| { + property( + label, + field, + if editable { + choice_value(selected, options) + } else { + PropValue::ReadOnly(selected.to_string()) + }, + ) + }; + + let s = style; + let dimfxlon = int(ov::DIMFXLON, s.dimfxlon as i16) != 0; + let dimtix = int(ov::DIMTIX, s.dimtix as i16) != 0; + let dimlunit = int(ov::DIMLUNIT, s.dimlunit); + let dimfrac = int(ov::DIMFRAC, s.dimfrac); + let dimalt = int(ov::DIMALT, s.dimalt as i16) != 0; + let dimtol = int(ov::DIMTOL, s.dimtol as i16) != 0; + let dimlim = int(ov::DIMLIM, s.dimlim as i16) != 0; + let dimtp = real(ov::DIMTP, s.dimtp); + let dimtm = real(ov::DIMTM, s.dimtm); + let dimgap = real(ov::DIMGAP, s.dimgap); + let tolerance_display = if dimgap < 0.0 { + "Basic" + } else if dimlim { "Limits" - } else if s.dimtol { + } else if dimtol && (dimtp - dimtm).abs() <= 1e-12 { + "Symmetrical" + } else if dimtol { "Deviation" } else { "None" }; + let tolerance_enabled = matches!( + tolerance_display, + "Symmetrical" | "Deviation" | "Limits" + ); + let alternate_tolerance_enabled = tolerance_enabled && dimalt; + let dimzin = int(ov::DIMZIN, s.dimzin); + let dimaltz = int(ov::DIMALTZ, s.dimaltz); + let dimtzin = int(ov::DIMTZIN, s.dimtzin); + let dimalttz = int(ov::DIMALTTZ, s.dimalttz); + let annotative = s.annotative + || !crate::scene::annotative::object_scale_memberships( + document, + dimension.base().common.handle, + ) + .is_empty(); - vec![ + let overridden_text_style = ov::handle(data, ov::DIMTXSTY); + let text_style_name = overridden_text_style + .and_then(|handle| { + document + .text_styles + .iter() + .find(|record| record.handle == handle) + .map(|record| record.name.clone()) + }) + .unwrap_or_else(|| s.dimtxsty.clone()); + let text_height_editable = document + .text_styles + .iter() + .find(|record| { + overridden_text_style + .is_some_and(|handle| record.handle == handle) + || (overridden_text_style.is_none() + && record.name.eq_ignore_ascii_case(&text_style_name)) + }) + .is_none_or(|record| !record.has_fixed_height()); + + let (dim_prefix, dim_suffix) = + split_measurement_template(&string(ov::DIMPOST, &s.dimpost)); + let (alt_prefix, alt_suffix) = + split_measurement_template(&string(ov::DIMAPOST, &s.dimapost)); + let decimal_separator = { + let value = int(ov::DIMDSEP, s.dimdsep); + let character = value as u8 as char; + if value > 0 && !character.is_control() { + character.to_string() + } else { + value.to_string() + } + }; + + let mut arrow_options: Vec = std::iter::once("Closed filled".to_string()) + .chain( + document + .block_records + .iter() + .map(|record| record.name.clone()) + .filter(|name| !name.is_empty()), + ) + .collect(); + let mut linetype_options: Vec = document + .line_types + .iter() + .map(|line_type| line_type.name.clone()) + .filter(|name| !name.is_empty()) + .collect(); + let text_style_options: Vec = document + .text_styles + .iter() + .map(|record| record.name.clone()) + .filter(|name| !name.is_empty()) + .collect(); + + let arrow_name = |code, inherited_handle, inherited_name: &str| { + ov::handle(data, code) + .map(|handle| block_name(document, handle, inherited_name)) + .unwrap_or_else(|| block_name(document, inherited_handle, inherited_name)) + }; + let linetype = |code, inherited| { + linetype_name(document, ov::handle(data, code).unwrap_or(inherited)) + }; + let arrow_1 = if matches!(dimension, Dimension::Radius(_) | Dimension::LargeRadial(_)) { + let inherited = if s.dimblk1.is_null() { s.dimblk } else { s.dimblk1 }; + let inherited_name = if s.dimblk1.is_null() { + &s.dimblk_name + } else { + &s.dimblk1_name + }; + block_name( + document, + ov::handle(data, ov::DIMBLK1) + .or_else(|| ov::handle(data, ov::DIMBLK)) + .unwrap_or(inherited), + inherited_name, + ) + } else { + let inherited = if matches!(dimension, Dimension::Diameter(_)) && !s.dimsah { + (s.dimblk, s.dimblk_name.as_str()) + } else { + (s.dimblk1, s.dimblk1_name.as_str()) + }; + arrow_name(ov::DIMBLK1, inherited.0, inherited.1) + }; + let arrow_2 = if matches!(dimension, Dimension::Diameter(_)) && !s.dimsah { + arrow_name(ov::DIMBLK2, s.dimblk, &s.dimblk_name) + } else { + arrow_name(ov::DIMBLK2, s.dimblk2, &s.dimblk2_name) + }; + for current in [&arrow_1, &arrow_2] { + if !arrow_options.contains(current) { + arrow_options.push(current.clone()); + } + } + for current in [ + linetype(ov::DIMLTYPE, s.dimltex_handle), + linetype(ov::DIMLTEX1, s.dimltex1_handle), + linetype(ov::DIMLTEX2, s.dimltex2_handle), + ] { + if !linetype_options.contains(¤t) { + linetype_options.push(current); + } + } + + let precision_options: Vec = (0..=8).map(|value| value.to_string()).collect(); + let linear_units = [ + "Scientific", + "Decimal", + "Engineering", + "Architectural", + "Fractional", + "Desktop", + ]; + let alternate_unit_options = [ + "Scientific", + "Decimal", + "Engineering", + "Architectural stacked", + "Fractional stacked", + "Architectural", + "Fractional", + "Desktop", + ]; + + let fill_mode = int(ov::DIMTFILL, s.dimtfill); + let fill_color = ov::color(data, ov::DIMTFILLCLR) + .unwrap_or_else(|| AcadColor::from_index(s.dimtfillclr)); + let fill_value = match fill_mode { + 1 => choice_value("Background", &["None", "Background", "Color"]), + 2 => PropValue::ColorChoice(fill_color), + _ => choice_value("None", &["None", "Background", "Color"]), + }; + + let mut sections = vec![ PropSection { title: t!("Lines & Arrows").into_owned(), props: vec![ - ro(t!("Arrow size").as_ref(), "dim_arrow_size", f(s.dimasz)), - ro(t!("Dim line color").as_ref(), "dim_line_color", s.dimclrd.to_string()), - ro( + property( + t!("Arrow 1").as_ref(), + "dim_arrowhead_1", + PropValue::Choice { + selected: arrow_1, + options: arrow_options.clone(), + }, + ), + property( + t!("Arrow 2").as_ref(), + "dim_arrowhead_2", + PropValue::Choice { + selected: arrow_2, + options: arrow_options, + }, + ), + number( + t!("Arrow size").as_ref(), + "dim_arrow_size", + real(ov::DIMASZ, s.dimasz), + true, + ), + property( t!("Dim line lineweight").as_ref(), "dim_line_lineweight", - s.dimlwd.to_string(), + PropValue::Choice { + selected: lineweight_label(int(ov::DIMLWD, s.dimlwd)), + options: lineweight_options(), + }, ), - ro(t!("Ext line color").as_ref(), "dim_ext_line_color", s.dimclre.to_string()), - ro( + property( t!("Ext line lineweight").as_ref(), "dim_ext_line_lineweight", - s.dimlwe.to_string(), + PropValue::Choice { + selected: lineweight_label(int(ov::DIMLWE, s.dimlwe)), + options: lineweight_options(), + }, ), - ro(t!("Dim line 1").as_ref(), "dim_line_1", onoff(!s.dimsd1)), - ro(t!("Dim line 2").as_ref(), "dim_line_2", onoff(!s.dimsd2)), - ro(t!("Ext line 1").as_ref(), "dim_ext_line_1", onoff(!s.dimse1)), - ro(t!("Ext line 2").as_ref(), "dim_ext_line_2", onoff(!s.dimse2)), - ro(t!("Dim line ext").as_ref(), "dim_line_ext", f(s.dimdle)), - ro(t!("Ext line ext").as_ref(), "dim_ext_line_ext", f(s.dimexe)), - ro(t!("Ext line offset").as_ref(), "dim_ext_line_offset", f(s.dimexo)), - ro(t!("Ext line fixed").as_ref(), "dim_ext_line_fixed", yn(s.dimfxlon)), - ro( + choice( + t!("Dim line 1").as_ref(), + "dim_line_1", + on(int(ov::DIMSD1, s.dimsd1 as i16) == 0), + &["On", "Off"], + true, + ), + choice( + t!("Dim line 2").as_ref(), + "dim_line_2", + on(int(ov::DIMSD2, s.dimsd2 as i16) == 0), + &["On", "Off"], + true, + ), + property( + t!("Dim line color").as_ref(), + "dim_line_color", + PropValue::ColorChoice( + ov::color(data, ov::DIMCLRD) + .unwrap_or_else(|| AcadColor::from_index(s.dimclrd)), + ), + ), + property( + t!("Dim line linetype").as_ref(), + "dim_linetype", + PropValue::Choice { + selected: linetype(ov::DIMLTYPE, s.dimltex_handle), + options: linetype_options.clone(), + }, + ), + number( + t!("Dim line ext").as_ref(), + "dim_line_ext", + real(ov::DIMDLE, s.dimdle), + true, + ), + property( + t!("Ext line 1 linetype").as_ref(), + "dim_ext_linetype_1", + PropValue::Choice { + selected: linetype(ov::DIMLTEX1, s.dimltex1_handle), + options: linetype_options.clone(), + }, + ), + property( + t!("Ext line 2 linetype").as_ref(), + "dim_ext_linetype_2", + PropValue::Choice { + selected: linetype(ov::DIMLTEX2, s.dimltex2_handle), + options: linetype_options, + }, + ), + choice( + t!("Ext line 1").as_ref(), + "dim_ext_line_1", + on(int(ov::DIMSE1, s.dimse1 as i16) == 0), + &["On", "Off"], + true, + ), + choice( + t!("Ext line 2").as_ref(), + "dim_ext_line_2", + on(int(ov::DIMSE2, s.dimse2 as i16) == 0), + &["On", "Off"], + true, + ), + choice( + t!("Ext line fixed").as_ref(), + "dim_ext_line_fixed", + on(dimfxlon), + &["On", "Off"], + true, + ), + number( t!("Ext line fixed length").as_ref(), "dim_ext_line_fixed_length", - f(s.dimfxl), + real(ov::DIMFXL, s.dimfxl), + dimfxlon, + ), + property( + t!("Ext line color").as_ref(), + "dim_ext_line_color", + PropValue::ColorChoice( + ov::color(data, ov::DIMCLRE) + .unwrap_or_else(|| AcadColor::from_index(s.dimclre)), + ), + ), + number( + t!("Ext line ext").as_ref(), + "dim_ext_line_ext", + real(ov::DIMEXE, s.dimexe), + true, + ), + number( + t!("Ext line offset").as_ref(), + "dim_ext_line_offset", + real(ov::DIMEXO, s.dimexo), + true, ), ], }, PropSection { title: t!("Text").into_owned(), props: vec![ - ro(t!("Fill color").as_ref(), "dim_text_fill_color", s.dimtfillclr.to_string()), - ro(t!("Text color").as_ref(), "dim_text_color", s.dimclrt.to_string()), - ro(t!("Text height").as_ref(), "dim_text_height", f(s.dimtxt)), - ro(t!("Text offset").as_ref(), "dim_text_offset", f(s.dimgap)), - ro(t!("Text pos vert").as_ref(), "dim_text_pos_vert", s.dimtad.to_string()), - ro(t!("Text pos hor").as_ref(), "dim_text_pos_hor", s.dimjust.to_string()), - ro(t!("Text outside align").as_ref(), "dim_text_outside_align", yn(s.dimtoh)), - ro(t!("Text inside align").as_ref(), "dim_text_inside_align", yn(s.dimtih)), - ro(t!("Text style").as_ref(), "dim_text_style", s.dimtxsty.clone()), + property( + t!("Fill color").as_ref(), + "dim_text_fill_color", + fill_value, + ), + choice( + t!("Fractional type").as_ref(), + "dim_fractional_type", + fraction_type_label(dimfrac), + &["Horizontal", "Diagonal", "Not stacked"], + matches!(dimlunit, 4 | 5), + ), + property( + t!("Text color").as_ref(), + "dim_text_color", + PropValue::ColorChoice( + ov::color(data, ov::DIMCLRT) + .unwrap_or_else(|| AcadColor::from_index(s.dimclrt)), + ), + ), + number( + t!("Text height").as_ref(), + "dim_text_height", + real(ov::DIMTXT, s.dimtxt), + text_height_editable, + ), + number( + t!("Text offset").as_ref(), + "dim_text_offset", + dimgap.abs(), + true, + ), + choice( + t!("Text outside align").as_ref(), + "dim_text_outside_align", + on(int(ov::DIMTOH, s.dimtoh as i16) != 0), + &["On", "Off"], + true, + ), + choice( + t!("Text pos hor").as_ref(), + "dim_text_pos_hor", + text_horizontal_label(int(ov::DIMJUST, s.dimjust)), + &[ + "Centered", + "At extension line 1", + "At extension line 2", + "Over extension line 1", + "Over extension line 2", + ], + true, + ), + choice( + t!("Text pos vert").as_ref(), + "dim_text_pos_vert", + text_vertical_label(int(ov::DIMTAD, s.dimtad)), + &["Centered", "Above", "Outside", "JIS", "Below"], + true, + ), + property( + t!("Text style").as_ref(), + "dim_text_style", + PropValue::Choice { + selected: text_style_name, + options: text_style_options, + }, + ), + choice( + t!("Text inside align").as_ref(), + "dim_text_inside_align", + on(int(ov::DIMTIH, s.dimtih as i16) != 0), + &["On", "Off"], + dimtix, + ), + property( + t!("Text position X").as_ref(), + "text_x", + PropValue::EditText(format!( + "{:.4}", + dimension.base().text_middle_point.x + )), + ), + property( + t!("Text position Y").as_ref(), + "text_y", + PropValue::EditText(format!( + "{:.4}", + dimension.base().text_middle_point.y + )), + ), + property( + t!("Text rotation").as_ref(), + "text_rotation", + PropValue::EditText(format!( + "{:.4}", + dimension.base().text_rotation.to_degrees() + )), + ), + choice( + t!("Text view direction").as_ref(), + "dim_text_view_direction", + if int(ov::DIMTXTDIRECTION, s.dimtxtdirection as i16) != 0 { + "Right-to-left" + } else { + "Left-to-right" + }, + &["Left-to-right", "Right-to-left"], + true, + ), + property( + t!("Measurement").as_ref(), + "measurement", + PropValue::ReadOnly(format!("{:.4}", dimension.measurement())), + ), + property( + t!("Text override").as_ref(), + "text_override", + PropValue::PlainText( + dimension_text_override(dimension.base()) + .unwrap_or("") + .to_string(), + ), + ), ], }, PropSection { title: t!("Fit").into_owned(), props: vec![ - ro(t!("Fit").as_ref(), "dim_fit", s.dimatfit.to_string()), - ro(t!("Text inside").as_ref(), "dim_text_inside", yn(s.dimtix)), - ro(t!("Text movement").as_ref(), "dim_text_movement", s.dimtmove.to_string()), - ro(t!("Dim scale overall").as_ref(), "dim_scale_overall", f(s.dimscale)), - ro(t!("Dim line forced").as_ref(), "dim_line_forced", yn(s.dimtofl)), - ro(t!("Dim line inside").as_ref(), "dim_line_inside", yn(s.dimsoxd)), + choice( + t!("Fit").as_ref(), + "dim_fit", + fit_label(int(ov::DIMATFIT, s.dimatfit)), + &["Both text and arrows", "Arrows", "Text", "Best fit"], + true, + ), + choice( + t!("Text inside").as_ref(), + "dim_text_inside", + on(dimtix), + &["On", "Off"], + true, + ), + choice( + t!("Text movement").as_ref(), + "dim_text_movement", + text_movement_label(int(ov::DIMTMOVE, s.dimtmove)), + &[ + "Keep dim line with text", + "Move text, add leader", + "Move text, no leader", + ], + true, + ), + number( + t!("Dim scale overall").as_ref(), + "dim_scale_overall", + real(ov::DIMSCALE, s.dimscale), + !annotative, + ), + choice( + t!("Dim line forced").as_ref(), + "dim_line_forced", + on(int(ov::DIMTOFL, s.dimtofl as i16) != 0), + &["On", "Off"], + true, + ), + choice( + t!("Dim line inside").as_ref(), + "dim_line_inside", + on(int(ov::DIMSOXD, s.dimsoxd as i16) != 0), + &["On", "Off"], + true, + ), ], }, PropSection { title: t!("Primary Units").into_owned(), props: vec![ - ro(t!("Dim units").as_ref(), "dim_units", s.dimlunit.to_string()), - ro(t!("Precision").as_ref(), "dim_precision", s.dimdec.to_string()), - ro(t!("Decimal separator").as_ref(), "dim_decimal_separator", dsep), - ro(t!("Dim prefix/suffix").as_ref(), "dim_prefix_suffix", s.dimpost.clone()), - ro(t!("Dim roundoff").as_ref(), "dim_roundoff", f(s.dimrnd)), - ro( + text( + t!("Decimal separator").as_ref(), + "dim_decimal_separator", + decimal_separator.clone(), + true, + ), + text( + t!("Prefix").as_ref(), + "dim_prefix", + dim_prefix.clone(), + true, + ), + text( + t!("Suffix").as_ref(), + "dim_suffix", + dim_suffix.clone(), + true, + ), + text( + t!("Dim sub-units suffix").as_ref(), + "dim_sub_units_suffix", + string(ov::DIMMZS, &s.dimmzs), + dimzin & 4 != 0, + ), + number( + t!("Dim roundoff").as_ref(), + "dim_roundoff", + real(ov::DIMRND, s.dimrnd), + true, + ), + number( + t!("Dim scale linear").as_ref(), + "dim_scale_linear", + real(ov::DIMLFAC, s.dimlfac), + true, + ), + number( + t!("Dim sub-units scale").as_ref(), + "dim_sub_units_scale", + real(ov::DIMMZF, s.dimmzf), + dimzin & 4 != 0, + ), + choice( + t!("Dim units").as_ref(), + "dim_units", + linear_unit_label(dimlunit), + &linear_units, + true, + ), + choice( t!("Suppress leading zeros").as_ref(), "dim_suppress_leading_zeros", - yn(s.dimzin & 4 != 0), + yes(dimzin & 4 != 0), + &["Yes", "No"], + true, ), - ro( + choice( t!("Suppress trailing zeros").as_ref(), "dim_suppress_trailing_zeros", - yn(s.dimzin & 8 != 0), + yes(dimzin & 8 != 0), + &["Yes", "No"], + true, + ), + choice( + t!("Suppress zero feet").as_ref(), + "dim_suppress_zero_feet", + yes(suppresses_zero_feet(dimzin)), + &["Yes", "No"], + true, + ), + choice( + t!("Suppress zero inches").as_ref(), + "dim_suppress_zero_inches", + yes(suppresses_zero_inches(dimzin)), + &["Yes", "No"], + true, + ), + property( + t!("Precision").as_ref(), + "dim_precision", + PropValue::Choice { + selected: int(ov::DIMDEC, s.dimdec).to_string(), + options: precision_options.clone(), + }, ), - ro(t!("Dim scale linear").as_ref(), "dim_scale_linear", f(s.dimlfac)), - ro(t!("Angle format").as_ref(), "dim_angle_format", s.dimaunit.to_string()), - ro(t!("Angle precision").as_ref(), "dim_angle_precision", s.dimadec.to_string()), ], }, PropSection { title: t!("Alternate Units").into_owned(), props: vec![ - ro(t!("Alt enabled").as_ref(), "dim_alt_enabled", yn(s.dimalt)), - ro(t!("Alt format").as_ref(), "dim_alt_format", s.dimaltu.to_string()), - ro(t!("Alt precision").as_ref(), "dim_alt_precision", s.dimaltd.to_string()), - ro(t!("Alt scale factor").as_ref(), "dim_alt_scale_factor", f(s.dimaltf)), - ro(t!("Alt roundoff").as_ref(), "dim_alt_roundoff", f(s.dimaltrnd)), - ro(t!("Alt prefix/suffix").as_ref(), "dim_alt_prefix_suffix", s.dimapost.clone()), - ro( + choice( + t!("Alt enabled").as_ref(), + "dim_alt_enabled", + on(dimalt), + &["On", "Off"], + true, + ), + choice( + t!("Alt format").as_ref(), + "dim_alt_format", + alternate_unit_label(int(ov::DIMALTU, s.dimaltu)), + &alternate_unit_options, + dimalt, + ), + property( + t!("Alt precision").as_ref(), + "dim_alt_precision", + if dimalt { + PropValue::Choice { + selected: int(ov::DIMALTD, s.dimaltd).to_string(), + options: precision_options.clone(), + } + } else { + PropValue::ReadOnly(int(ov::DIMALTD, s.dimaltd).to_string()) + }, + ), + number( + t!("Alt scale factor").as_ref(), + "dim_alt_scale_factor", + real(ov::DIMALTF, s.dimaltf), + dimalt, + ), + number( + t!("Alt sub-units scale").as_ref(), + "dim_alt_sub_units_scale", + real(ov::DIMALTMZF, s.dimaltmzf), + dimalt && dimaltz & 4 != 0, + ), + number( + t!("Alt roundoff").as_ref(), + "dim_alt_roundoff", + real(ov::DIMALTRND, s.dimaltrnd), + dimalt, + ), + text( + t!("Alt prefix").as_ref(), + "dim_alt_prefix", + alt_prefix, + dimalt, + ), + text( + t!("Alt suffix").as_ref(), + "dim_alt_suffix", + alt_suffix, + dimalt, + ), + text( + t!("Alt sub-units suffix").as_ref(), + "dim_alt_sub_units_suffix", + string(ov::DIMALTMZS, &s.dimaltmzs), + dimalt && dimaltz & 4 != 0, + ), + choice( t!("Alt suppress leading zeros").as_ref(), "dim_alt_suppress_leading_zeros", - yn(s.dimaltz & 4 != 0), + yes(dimaltz & 4 != 0), + &["Yes", "No"], + dimalt, ), - ro( + choice( t!("Alt suppress trailing zeros").as_ref(), "dim_alt_suppress_trailing_zeros", - yn(s.dimaltz & 8 != 0), + yes(dimaltz & 8 != 0), + &["Yes", "No"], + dimalt, + ), + choice( + t!("Alt suppress zero feet").as_ref(), + "dim_alt_suppress_zero_feet", + yes(suppresses_zero_feet(dimaltz)), + &["Yes", "No"], + dimalt, + ), + choice( + t!("Alt suppress zero inches").as_ref(), + "dim_alt_suppress_zero_inches", + yes(suppresses_zero_inches(dimaltz)), + &["Yes", "No"], + dimalt, ), ], }, PropSection { title: t!("Tolerances").into_owned(), props: vec![ - ro(t!("Tolerance display").as_ref(), "dim_tolerance_display", tol_display), - ro(t!("Tolerance limit lower").as_ref(), "dim_tolerance_limit_lower", f(s.dimtm)), - ro(t!("Tolerance limit upper").as_ref(), "dim_tolerance_limit_upper", f(s.dimtp)), - ro( + choice( + t!("Tolerance display").as_ref(), + "dim_tolerance_display", + tolerance_display, + &["None", "Symmetrical", "Deviation", "Limits", "Basic"], + true, + ), + property( t!("Tolerance precision").as_ref(), "dim_tolerance_precision", - s.dimtdec.to_string(), + if tolerance_enabled { + PropValue::Choice { + selected: int(ov::DIMTDEC, s.dimtdec).to_string(), + options: precision_options.clone(), + } + } else { + PropValue::ReadOnly(int(ov::DIMTDEC, s.dimtdec).to_string()) + }, ), - ro( + number( + t!("Tolerance limit lower").as_ref(), + "dim_tolerance_limit_lower", + dimtm, + matches!(tolerance_display, "Deviation" | "Limits"), + ), + number( + t!("Tolerance limit upper").as_ref(), + "dim_tolerance_limit_upper", + dimtp, + tolerance_enabled, + ), + number( t!("Tolerance text height").as_ref(), "dim_tolerance_text_height", - f(s.dimtfac), + real(ov::DIMTFAC, s.dimtfac), + tolerance_enabled, ), - ro( + choice( t!("Tolerance pos vert").as_ref(), "dim_tolerance_pos_vert", - s.dimtolj.to_string(), + tolerance_vertical_label(int(ov::DIMTOLJ, s.dimtolj)), + &["Bottom", "Middle", "Top"], + true, ), - ro( + choice( + t!("Tolerance alignment").as_ref(), + "dim_tolerance_alignment", + tolerance_alignment_label(int(ov::DIMTALN, 0)), + &["Align decimal separators", "Align operational symbols"], + true, + ), + choice( t!("Tolerance suppress leading zeros").as_ref(), "dim_tolerance_suppress_leading_zeros", - yn(s.dimtzin & 4 != 0), + yes(dimtzin & 4 != 0), + &["Yes", "No"], + tolerance_enabled, ), - ro( + choice( t!("Tolerance suppress trailing zeros").as_ref(), "dim_tolerance_suppress_trailing_zeros", - yn(s.dimtzin & 8 != 0), + yes(dimtzin & 8 != 0), + &["Yes", "No"], + tolerance_enabled, + ), + choice( + t!("Tolerance suppress zero feet").as_ref(), + "dim_tolerance_suppress_zero_feet", + yes(suppresses_zero_feet(dimtzin)), + &["Yes", "No"], + tolerance_enabled, + ), + choice( + t!("Tolerance suppress zero inches").as_ref(), + "dim_tolerance_suppress_zero_inches", + yes(suppresses_zero_inches(dimtzin)), + &["Yes", "No"], + tolerance_enabled, + ), + property( + t!("Alt tolerance precision").as_ref(), + "dim_alt_tolerance_precision", + if alternate_tolerance_enabled { + PropValue::Choice { + selected: int(ov::DIMALTTD, s.dimalttd).to_string(), + options: precision_options, + } + } else { + PropValue::ReadOnly(int(ov::DIMALTTD, s.dimalttd).to_string()) + }, + ), + choice( + t!("Alt tolerance suppress leading zeros").as_ref(), + "dim_alt_tolerance_suppress_leading_zeros", + yes(dimalttz & 4 != 0), + &["Yes", "No"], + alternate_tolerance_enabled, + ), + choice( + t!("Alt tolerance suppress trailing zeros").as_ref(), + "dim_alt_tolerance_suppress_trailing_zeros", + yes(dimalttz & 8 != 0), + &["Yes", "No"], + alternate_tolerance_enabled, + ), + choice( + t!("Alt tolerance suppress zero feet").as_ref(), + "dim_alt_tolerance_suppress_zero_feet", + yes(suppresses_zero_feet(dimalttz)), + &["Yes", "No"], + alternate_tolerance_enabled, + ), + choice( + t!("Alt tolerance suppress zero inches").as_ref(), + "dim_alt_tolerance_suppress_zero_inches", + yes(suppresses_zero_inches(dimalttz)), + &["Yes", "No"], + alternate_tolerance_enabled, ), ], }, - ] + ]; + if matches!( + dimension, + Dimension::Radius(_) | Dimension::Diameter(_) | Dimension::LargeRadial(_) + ) { + let dimcen = real(ov::DIMCEN, s.dimcen); + let center_type = if dimcen > 1e-12 { + "Center marks" + } else if dimcen < -1e-12 { + "Centerlines" + } else { + "None" + }; + if let Some(lines) = sections + .iter_mut() + .find(|section| section.title == t!("Lines & Arrows").as_ref()) + { + const RADIUS_LINE_FIELDS: &[&str] = &[ + "dim_arrowhead_1", + "dim_arrow_size", + "dim_line_lineweight", + "dim_ext_line_lineweight", + "dim_line_1", + "dim_line_color", + "dim_linetype", + "dim_ext_linetype_1", + "dim_ext_line_1", + "dim_ext_line_color", + "dim_ext_line_ext", + "dim_ext_line_offset", + ]; + const DIAMETER_LINE_FIELDS: &[&str] = &[ + "dim_arrowhead_1", + "dim_arrowhead_2", + "dim_arrow_size", + "dim_line_lineweight", + "dim_ext_line_lineweight", + "dim_line_1", + "dim_line_2", + "dim_line_color", + "dim_linetype", + "dim_ext_linetype_1", + "dim_ext_line_1", + "dim_ext_line_color", + "dim_ext_line_ext", + "dim_ext_line_offset", + ]; + const LARGE_RADIAL_LINE_FIELDS: &[&str] = &[ + "dim_arrowhead_1", + "dim_arrow_size", + "dim_line_lineweight", + "dim_line_1", + "dim_line_color", + "dim_linetype", + ]; + let retained = if matches!(dimension, Dimension::Diameter(_)) { + DIAMETER_LINE_FIELDS + } else if matches!(dimension, Dimension::LargeRadial(_)) { + LARGE_RADIAL_LINE_FIELDS + } else { + RADIUS_LINE_FIELDS + }; + lines + .props + .retain(|property| retained.contains(&property.field)); + lines.props.push(choice( + t!("Center mark").as_ref(), + "dim_center_type", + center_type, + &["None", "Center marks", "Centerlines"], + true, + )); + lines.props.push(number( + t!("Center mark size").as_ref(), + "dim_center_size", + dimcen.abs(), + center_type != "None", + )); + } + if let Some(primary_units) = sections + .iter_mut() + .find(|section| section.title == t!("Primary Units").as_ref()) + { + primary_units.props.retain(|property| { + !matches!( + property.field, + "dim_sub_units_suffix" | "dim_sub_units_scale" + ) + }); + } + } + + 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() + .find(|section| section.title == t!("Text").as_ref()) + { + text_section + .props + .retain(|property| property.field != "dim_fractional_type"); + } + + let angle_zero_suppression = int(ov::DIMAZIN, s.dimazin); + let angle_unit = int(ov::DIMAUNIT, s.dimaunit); + if let Some(primary_units) = sections + .iter_mut() + .find(|section| section.title == t!("Primary Units").as_ref()) + { + primary_units.props = vec![ + text( + t!("Decimal separator").as_ref(), + "dim_decimal_separator", + decimal_separator, + true, + ), + text(t!("Prefix").as_ref(), "dim_prefix", dim_prefix, true), + text(t!("Suffix").as_ref(), "dim_suffix", dim_suffix, true), + choice( + t!("Angle format").as_ref(), + "dim_angle_units", + angular_unit_label(angle_unit), + &[ + "Decimal degrees", + "Degrees/minutes/seconds", + "Gradians", + "Radians", + ], + true, + ), + choice( + t!("Suppress leading zeros").as_ref(), + "dim_angle_suppress_leading_zeros", + yes(angle_zero_suppression & 1 != 0), + &["Yes", "No"], + true, + ), + choice( + t!("Suppress trailing zeros").as_ref(), + "dim_angle_suppress_trailing_zeros", + yes(angle_zero_suppression & 2 != 0), + &["Yes", "No"], + true, + ), + property( + t!("Precision").as_ref(), + "dim_angle_precision", + PropValue::Choice { + selected: int(ov::DIMADEC, s.dimadec).to_string(), + options: (-1..=8).map(|value| value.to_string()).collect(), + }, + ), + ]; + } + sections.retain(|section| section.title != t!("Alternate Units").as_ref()); + if let Some(tolerances) = sections + .iter_mut() + .find(|section| section.title == t!("Tolerances").as_ref()) + { + tolerances + .props + .retain(|property| !property.field.starts_with("dim_alt_tolerance_")); + } + } + + if matches!(dimension, Dimension::Ordinate(_)) { + for section in &mut sections { + section.props.retain(|property| match section.title.as_str() { + title if title == t!("Lines & Arrows").as_ref() => matches!( + property.field, + "dim_arrow_size" + | "dim_ext_line_lineweight" + | "dim_ext_linetype_1" + | "dim_ext_line_1" + | "dim_ext_line_fixed" + | "dim_ext_line_fixed_length" + | "dim_ext_line_color" + | "dim_ext_line_offset" + ), + title if title == t!("Text").as_ref() => !matches!( + property.field, + "dim_text_outside_align" | "dim_text_pos_hor" | "dim_text_inside_align" + ), + title if title == t!("Fit").as_ref() => matches!( + property.field, + "dim_text_movement" | "dim_scale_overall" + ), + _ => true, + }); + } + let dim_scale = if s.dimscale > 1e-6 { s.dimscale } else { 1.0 }; + let text_position = dimension_text_pos_f64( + dimension, + Some(s), + real(ov::DIMTXT, s.dimtxt) * dim_scale, + dim_scale, + ); + for section in &mut sections { + for property in &mut section.props { + match property.field { + "text_x" => { + property.value = PropValue::EditText(format!("{:.4}", text_position.x)); + } + "text_y" => { + property.value = PropValue::EditText(format!("{:.4}", text_position.y)); + } + _ => {} + } + } + } + } + sections } + +fn property(label: &str, field: &'static str, value: PropValue) -> Property { + Property { + label: label.to_string(), + field, + value, + } +} + +fn split_measurement_template(value: &str) -> (String, String) { + value + .split_once("<>") + .map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string())) + .unwrap_or_else(|| (String::new(), value.to_string())) +} + +fn fraction_type_label(value: i16) -> &'static str { + match value { + 1 => "Diagonal", + 2 => "Not stacked", + _ => "Horizontal", + } +} + +fn suppresses_zero_feet(value: i16) -> bool { + matches!(value & 3, 0 | 3) +} + +fn suppresses_zero_inches(value: i16) -> bool { + matches!(value & 3, 0 | 2) +} + +fn text_horizontal_label(value: i16) -> &'static str { + match value { + 1 => "At extension line 1", + 2 => "At extension line 2", + 3 => "Over extension line 1", + 4 => "Over extension line 2", + _ => "Centered", + } +} + +fn text_vertical_label(value: i16) -> &'static str { + match value { + 1 => "Above", + 2 => "Outside", + 3 => "JIS", + 4 => "Below", + _ => "Centered", + } +} + +fn fit_label(value: i16) -> &'static str { + match value { + 1 => "Arrows", + 2 => "Text", + 3 => "Best fit", + _ => "Both text and arrows", + } +} + +fn text_movement_label(value: i16) -> &'static str { + match value { + 1 => "Move text, add leader", + 2 => "Move text, no leader", + _ => "Keep dim line with text", + } +} + +fn alternate_unit_label(value: i16) -> &'static str { + match value { + 1 => "Scientific", + 3 => "Engineering", + 4 => "Architectural stacked", + 5 => "Fractional stacked", + 6 => "Architectural", + 7 => "Fractional", + 8 => "Desktop", + _ => "Decimal", + } +} + +fn angular_unit_label(value: i16) -> &'static str { + match value { + 1 => "Degrees/minutes/seconds", + 2 => "Gradians", + 3 => "Radians", + _ => "Decimal degrees", + } +} + +fn tolerance_vertical_label(value: i16) -> &'static str { + match value { + 0 => "Bottom", + 2 => "Top", + _ => "Middle", + } +} + +fn tolerance_alignment_label(value: i16) -> &'static str { + if value == 0 { + "Align decimal separators" + } else { + "Align operational symbols" + } +} + +fn choice_value( + selected: &str, + options: &[&str], +) -> crate::scene::model::object::PropValue { + crate::scene::model::object::PropValue::Choice { + selected: selected.to_string(), + options: options.iter().map(|value| (*value).to_string()).collect(), + } +} + +fn block_name(document: &CadDocument, handle: acadrust::Handle, fallback: &str) -> String { + if handle.is_null() { + return "Closed filled".to_string(); + } + document + .block_records + .iter() + .find(|record| record.handle == handle) + .map(|record| record.name.clone()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn linetype_name(document: &CadDocument, handle: acadrust::Handle) -> String { + document + .line_types + .iter() + .find(|line_type| line_type.handle == handle) + .map(|line_type| line_type.name.clone()) + .unwrap_or_else(|| "ByBlock".to_string()) +} + +fn linear_unit_label(value: i16) -> &'static str { + match value { + 1 => "Scientific", + 3 => "Engineering", + 4 => "Architectural", + 5 => "Fractional", + 6 => "Desktop", + _ => "Decimal", + } +} + use acadrust::{CadDocument, EntityType, Handle}; use crate::scene::convert::tess_util::aci_to_rgba; @@ -1511,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, @@ -1675,6 +3120,50 @@ fn tessellate_dimension_inner( view_aabb: Option<[f32; 4]>, world_per_pixel: Option, ) -> Vec { + 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.) @@ -1736,6 +3225,8 @@ fn tessellate_dimension_inner( real!(dimtfac, DIMTFAC); real!(dimgap, DIMGAP); real!(dimaltrnd, DIMALTRND); + real!(dimaltmzf, DIMALTMZF); + real!(dimmzf, DIMMZF); flag!(dimtol, DIMTOL); flag!(dimlim, DIMLIM); flag!(dimtih, DIMTIH); @@ -1753,6 +3244,7 @@ fn tessellate_dimension_inner( flag!(dimfxlon, DIMFXLON); flag!(dimtxtdirection, DIMTXTDIRECTION); int!(dimzin, DIMZIN); + int!(dimtad, DIMTAD); int!(dimazin, DIMAZIN); int!(dimarcsym, DIMARCSYM); int!(dimclrd, DIMCLRD); @@ -1792,6 +3284,12 @@ fn tessellate_dimension_inner( if let Some(value) = ov::string(data, ov::DIMAPOST) { style.dimapost = value; } + if let Some(value) = ov::string(data, ov::DIMALTMZS) { + style.dimaltmzs = value; + } + if let Some(value) = ov::string(data, ov::DIMMZS) { + style.dimmzs = value; + } if let Some(value) = ov::handle(data, ov::DIMTXSTY) { style.dimtxsty_handle = value; if let Some(record) = document.text_styles.iter().find(|record| record.handle == value) { @@ -1876,7 +3374,42 @@ fn tessellate_dimension_inner( }; (t.clone(), t) } else if let Some(s) = style { - if dimsah { + if matches!(dim, Dimension::Radius(_)) { + let handle = if s.dimblk1.is_null() { s.dimblk } else { s.dimblk1 }; + let arrow = arrow_from_block_with_deferred_hatch( + document, + handle, + dimasz, + defer_arrow_hatches, + ); + (arrow.clone(), arrow) + } else if matches!(dim, Dimension::Diameter(_)) { + let data = &dim.base().common.extended_data; + let first = crate::entities::dim_override::handle( + data, + crate::entities::dim_override::DIMBLK1, + ) + .unwrap_or(if dimsah { s.dimblk1 } else { s.dimblk }); + let second = crate::entities::dim_override::handle( + data, + crate::entities::dim_override::DIMBLK2, + ) + .unwrap_or(if dimsah { s.dimblk2 } else { s.dimblk }); + ( + arrow_from_block_with_deferred_hatch( + document, + first, + dimasz, + defer_arrow_hatches, + ), + arrow_from_block_with_deferred_hatch( + document, + second, + dimasz, + defer_arrow_hatches, + ), + ) + } else if dimsah { ( arrow_from_block_with_deferred_hatch( document, @@ -1912,12 +3445,15 @@ fn tessellate_dimension_inner( // Text box (local space) so the dim line can be broken where the text // crosses it — lets a DIMTFILL background sit over the line. The renderer // draws 2D fills under all wires, so the line is gapped rather than masked. - let dimgap_local = style.map(|s| (s.dimgap * dim_scale) as f32).unwrap_or(0.09); + let dimgap_local = style + .map(|s| (s.dimgap.abs() * dim_scale) as f32) + .unwrap_or(0.09); + let text_width = dimension_text_value(dim, style) + .map(|text| text.chars().count() as f32 * dim_txt as f32 * 0.6 + dimgap_local * 2.0) + .unwrap_or(0.0); let text_break = { let tp = vec3_local(dimension_text_pos_f64(dim, style, dim_txt, dim_scale)); - let tw = dimension_text_value(dim, style) - .map(|t| t.chars().count() as f32 * dim_txt as f32 * 0.6) - .unwrap_or(0.0); + let tw = (text_width - dimgap_local * 2.0).max(0.0); if tw > 0.0 { // Vertical threshold is the bare text half-height (no DIMGAP): the // line only breaks when it actually passes under the glyphs. Text @@ -1932,6 +3468,11 @@ fn tessellate_dimension_inner( None } }; + let text_position = vec3_local(dimension_text_pos_f64(dim, style, dim_txt, dim_scale)); + let text_is_outside = dimension_text_is_outside(dim, style); + let horizontal_text = style.is_some_and(|style| { + (text_is_outside && style.dimtoh) || (!text_is_outside && style.dimtih) + }); let mut geom = dimension_geometry( dim, @@ -1947,6 +3488,12 @@ fn tessellate_dimension_inner( dimcen, ticks: dimtsz_raw > 1e-9, arrow_len: dimasz, + text_width, + dimatfit: style.map(|s| s.dimatfit).unwrap_or(3), + dimtofl: style.map(|s| s.dimtofl).unwrap_or(false), + text_position, + horizontal_text, + text_movement: style.map(|s| s.dimtmove).unwrap_or(0), text_break, }, SuppressFlags { @@ -1957,6 +3504,18 @@ fn tessellate_dimension_inner( }, ); + if !dimse1 { + if let Some(points) = crate::scene::dimension_assoc::radial_extension_points( + document, + handle, + dimexo as f64, + dimexe as f64, + ) { + let points: Vec = points.into_iter().map(vec3_local).collect(); + add_polyline(&mut geom.ext_lines, &points); + } + } + // DIMTMOVE = 1: when the saved text_middle_point sits far from the // dim-line anchor, draw a short leader connecting them. (=0 anchors text // to the dim line — no leader; =2 frees text without a leader.) @@ -1969,21 +3528,9 @@ fn tessellate_dimension_inner( } } } - // Fit: text that doesn't fit between the extension lines is slid outside - // in the text-placement pass (unless DIMTIX), and arrowheads that don't - // fit are flipped outside in append_linear_dimension. DIMTOFL (force the - // dim line inside) is already satisfied for linear/aligned, whose dim - // line is always drawn between the extension points. DIMATFIT's exact - // mode ordering (arrows-first vs text-first) and DIMUPT (reposition on - // create) aren't differentiated yet — read here for round-trip. - let _ = (s.dimtofl, s.dimatfit, s.dimupt); - // DIMTXTDIRECTION (RTL) needs per-instance text mirroring on the Text - // entity, which the current text struct can't carry. Tracked: read - // and ignore so the file round-trips on save. - let _ = s.dimtxtdirection; - // DIMARCSYM only applies to arc-length dims; DIMJOGANG only to - // jogged-radius dims. We don't ship those Dimension variants yet, - // so the values are read for round-trip but not drawn. + // DIMUPT governs interactive creation-time text placement; saved + // geometry already carries the resulting position. + let _ = s.dimupt; let _ = (s.dimarcsym, s.dimjogang); // DIMUNIT is the obsolete pre-R2000 linear unit format; DIMLUNIT // supersedes it. Read but not honoured. @@ -2068,6 +3615,7 @@ fn tessellate_dimension_inner( wires.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -2102,6 +3650,7 @@ fn tessellate_dimension_inner( wires.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -2136,6 +3685,7 @@ fn tessellate_dimension_inner( wires.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -2171,6 +3721,7 @@ fn tessellate_dimension_inner( wires.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -2206,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 { @@ -2222,6 +3813,7 @@ fn tessellate_dimension_inner( wires.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -2260,6 +3852,50 @@ fn tessellate_dimension_inner( } } + // A negative DIMGAP denotes the Basic tolerance display: frame the + // dimension text while keeping the absolute gap as the frame margin. + if style.is_some_and(|s| s.dimgap < 0.0) { + if let Some(rect) = text_fill_rect(dim, style, dim_txt, dim_scale) { + let p1 = rect[0]; + let p2 = rect[1]; + let p3 = rect[2]; + let p4 = rect[5]; + 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: vec![p1, p2, p2, p3, p3, p4, p4, p1], + 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(), + }); + } + } + if let Some(synth_text_entity) = dimension_text_entity(dim, dim_txt, style, document, dim_scale) { // Tolerance Text rendered separately so DIMTFAC scales its height @@ -2403,8 +4039,18 @@ fn dimtmove_leader_endpoints(dim: &Dimension) -> Option<(Vec3, Vec3)> { let off2 = def.dot(perp) - second.dot(perp); (first + perp * off1 + second + perp * off2) * 0.5 } - Dimension::Radius(d) => lv(d.definition_point), - Dimension::Diameter(d) => (lv(d.angle_vertex) + lv(d.definition_point)) * 0.5, + Dimension::Radius(_) => return None, + Dimension::Diameter(d) => { + let chord = lv(d.angle_vertex); + let far_chord = lv(d.definition_point); + if chord.distance_squared(lv(txt)) <= far_chord.distance_squared(lv(txt)) { + chord + } else { + far_chord + } + } + Dimension::Angular2Ln(d) => lv(d.dimension_arc), + Dimension::Angular3Pt(d) => lv(d.definition_point), _ => return None, }; Some((anchor, lv(txt))) @@ -2425,7 +4071,7 @@ fn text_fill_rect( return None; } let pos = dimension_text_pos_f64(dim, style, text_height, dim_scale); - let dimgap = style.map(|s| s.dimgap).unwrap_or(0.0).max(0.0) * dim_scale; + let dimgap = style.map(|s| s.dimgap.abs()).unwrap_or(0.0) * dim_scale; // ~0.6 × text_height per character; matches average glyph aspect for // the bundled stick fonts. Inflate by 1 DIMGAP on each side. let approx_w = value.chars().count() as f64 * text_height * 0.6 + dimgap * 2.0; @@ -2452,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> { + 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, @@ -2471,6 +4168,12 @@ struct DimLineParams { ticks: bool, /// Arrowhead length (DIMASZ, scaled) — used to decide arrow-outside fit. arrow_len: f32, + text_width: f32, + dimatfit: i16, + dimtofl: bool, + text_position: Vec3, + horizontal_text: bool, + text_movement: i16, /// Text box (local centre, half-width, half-height) used to break the /// dimension line where the text sits on it, so a DIMTFILL background reads /// over the line. None when the text doesn't overlap the line. @@ -2525,9 +4228,8 @@ fn dimension_geometry( Dimension::Radius(d) => { let center = lv(d.angle_vertex); let point = lv(d.definition_point); - let text = dimension_text_position(dim); - // A jogged radius dim (marked via XData) replaces the straight radial - // leader with a foreshortened zig-zag at ~45° near its midpoint. + let text = params.text_position; + // Jogged radius dimensions use a shortened zig-zag leader. let jogged = dim .base() .common @@ -2543,51 +4245,66 @@ fn dimension_geometry( let mid = center + u * (dist * 0.5); let a = mid - u * half + perp * half; let b = mid + u * half - perp * half; - add_segment(&mut g.dim_lines, center, a); - add_segment(&mut g.dim_lines, a, b); - add_segment(&mut g.dim_lines, b, point); - } else { + if !suppress.dim1 { + add_segment(&mut g.dim_lines, center, a); + add_segment(&mut g.dim_lines, a, b); + add_segment(&mut g.dim_lines, b, point); + } + } else if !suppress.dim1 { add_segment(&mut g.dim_lines, center, point); } - // Honour leader_length: extend from the arrow tip past it - // toward the text by that distance along (text - point). - let leader_dir = normalized_or(text - point, Vec3::X); - let leader = if d.leader_length.abs() > 1e-9 { - point + leader_dir * (d.leader_length as f32) - } else { - text - }; - add_segment(&mut g.dim_lines, point, leader); + let radius = (point - center).length(); + let text_is_outside = text.distance(center) > radius + 1e-5; + if text_is_outside && !suppress.dim1 { + let radial = normalized_or(point - center, Vec3::X); + let angle_from_horizontal = radial.y.abs().atan2(radial.x.abs()); + if params.horizontal_text + && angle_from_horizontal > 15.0_f32.to_radians() + && radial.y.abs() > 1e-6 + { + let travel = (text.y - point.y) / radial.y; + if travel > 0.0 { + let elbow = point + radial * travel; + let landing_gap = params.text_width * 0.5 + params.arrow_len; + let landing = Vec3::new( + text.x - radial.x.signum() * landing_gap, + text.y, + text.z, + ); + add_segment(&mut g.dim_lines, point, elbow); + add_segment(&mut g.dim_lines, elbow, landing); + } else { + add_segment(&mut g.dim_lines, point, text); + } + } else { + add_segment(&mut g.dim_lines, point, text); + } + } append_arrow( &mut g, point, normalized_or(center - point, Vec3::X), arrow1, ); - let radius = (point - center).length(); - append_center_mark(&mut g, center, params.dimcen, radius); + if text_is_outside { + append_center_mark(&mut g, center, params.dimcen, radius); + } } Dimension::Diameter(d) => { - // angle_vertex is the circle centre and definition_point a point on - // the circle. The diameter line runs edge-to-edge THROUGH the centre - // (far edge → near edge), with arrows pointing inward at each edge. - let center = lv(d.angle_vertex); - let edge = lv(d.definition_point); - let far = center * 2.0 - edge; - add_segment(&mut g.dim_lines, far, edge); - append_arrow(&mut g, edge, normalized_or(far - edge, Vec3::X), arrow1); - append_arrow(&mut g, far, normalized_or(edge - far, Vec3::X), arrow2); - // Diameter leader: continue past the near edge toward the text. - if d.leader_length.abs() > 1e-9 { - let text = dimension_text_position(dim); - let leader_dir = normalized_or(text - edge, edge - far); - add_segment( - &mut g.dim_lines, - edge, - edge + leader_dir * (d.leader_length as f32), - ); - } - let radius = (edge - center).length(); + let chord = lv(d.angle_vertex); + let far_chord = lv(d.definition_point); + append_diameter_dimension( + &mut g, + chord, + far_chord, + arrow1, + arrow2, + d.leader_length as f32, + params, + suppress, + ); + let center = (chord + far_chord) * 0.5; + let radius = chord.distance(far_chord) * 0.5; append_center_mark(&mut g, center, params.dimcen, radius); } Dimension::Angular2Ln(d) => { @@ -2604,12 +4321,14 @@ fn dimension_geometry( Some((vertex, start, end)) => append_angular_dimension( &mut g, vertex, - vertex + Vec3::new(start.cos(), start.sin(), 0.0) * vertex.distance(arc_point), - vertex + Vec3::new(end.cos(), end.sin(), 0.0) * vertex.distance(arc_point), + vertex, + vertex, arc_point, arrow1, arrow2, Some((start, end)), + params, + suppress, ), // Parallel lines have no vertex and so no angle to draw; the // extension lines alone say where the dimension was. @@ -2620,30 +4339,52 @@ fn dimension_geometry( } } Dimension::Angular3Pt(d) => { + let vertex = lv(d.angle_vertex); + let first = lv(d.first_point); + let second = lv(d.second_point); + let arc_point = lv(d.definition_point); + let explicit_sweep = two_line_angle_frame( + vertex, + first, + vertex, + second, + arc_point, + ) + .map(|(_, start, end)| (start, end)); append_angular_dimension( &mut g, - lv(d.angle_vertex), - lv(d.first_point), - lv(d.second_point), - lv(d.definition_point), + vertex, + first, + second, + arc_point, arrow1, arrow2, - None, + explicit_sweep, + params, + suppress, ); } Dimension::Ordinate(d) => { - add_segment( - &mut g.dim_lines, - lv(d.feature_location), - lv(d.definition_point), - ); - add_segment( - &mut g.dim_lines, - lv(d.definition_point), - lv(d.leader_endpoint), - ); + if !suppress.ext1 { + let fixed_length = params + .dimfxlon + .then_some(params.dimfxl.max(0.0) as f64); + let points = d.leader_polyline( + (params.arrow_len * 2.0) as f64, + params.dimexo as f64, + fixed_length, + ); + for pair in points.windows(2) { + let start = lv(pair[0]); + let end = lv(pair[1]); + if (end - start).length_squared() > 1e-12 { + add_segment(&mut g.ext_lines, start, end); + } + } + } } Dimension::Arc(d) => { + let explicit_sweep = arc_dimension_angles(d); append_angular_dimension( &mut g, lv(d.center_point), @@ -2652,10 +4393,9 @@ 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, ); if d.has_leader { add_segment( @@ -2671,15 +4411,23 @@ fn dimension_geometry( let override_center = lv(d.override_center); let (near, far) = jogged_radial_break(chord, jog, override_center, d.jog_angle as f32); - add_segment(&mut g.dim_lines, chord, near); - add_segment(&mut g.dim_lines, near, far); - add_segment(&mut g.dim_lines, far, override_center); + if !suppress.dim1 { + add_segment(&mut g.dim_lines, chord, near); + add_segment(&mut g.dim_lines, near, far); + add_segment(&mut g.dim_lines, far, override_center); + } append_arrow( &mut g, chord, normalized_or(near - chord, Vec3::X), arrow1, ); + append_center_mark( + &mut g, + lv(d.definition_point), + params.dimcen, + (chord - lv(d.definition_point)).length(), + ); } } g @@ -2778,12 +4526,22 @@ fn append_linear_dimension( let d1_out = d1 - dir_d1_to_d2 * dle; let d2_out = d2 + dir_d1_to_d2 * dle; - // DIMATFIT fit: when the arrowheads don't fit between the extension lines - // they're flipped to the outside (point still on the ext line, body - // outside) with a short stub for them to sit on. DIMSOXD suppresses those - // outer stubs. Ticks always fit, so this only affects arrowheads. + // When text plus arrows do not fit, DIMATFIT decides which component moves + // first: 0=both, 1=arrows, 2=text, 3=best fit. let gap = (d2 - d1).length(); - let arrows_outside = !params.ticks && params.arrow_len > 1e-6 && gap < 2.0 * params.arrow_len; + let arrows_outside = if params.ticks || params.arrow_len <= 1e-6 { + false + } else if gap < 2.0 * params.arrow_len { + true + } else if gap < params.text_width + 2.0 * params.arrow_len { + match params.dimatfit { + 0 | 1 => true, + 2 => false, + _ => params.text_width <= gap, + } + } else { + false + }; // The two suppression flags address the portions at the first and second // measured points independently. The text gap is also the natural split; @@ -2809,14 +4567,15 @@ fn append_linear_dimension( } } } - if !suppress.dim1 && left_end > 1e-6 { + let draw_inside_line = !arrows_outside || params.dimtofl; + if draw_inside_line && !suppress.dim1 && left_end > 1e-6 { add_segment( &mut g.dim_lines, d1_out, d1_out + line_dir * left_end, ); } - if !suppress.dim2 && line_len - right_start > 1e-6 { + if draw_inside_line && !suppress.dim2 && line_len - right_start > 1e-6 { add_segment( &mut g.dim_lines, d1_out + line_dir * right_start, @@ -2843,6 +4602,107 @@ fn append_linear_dimension( } } +fn append_diameter_dimension( + g: &mut DimGeom, + chord: Vec3, + far_chord: Vec3, + arrow1: &ArrowKind, + arrow2: &ArrowKind, + leader_length: f32, + params: DimLineParams, + suppress: SuppressFlags, +) { + let axis = normalized_or(far_chord - chord, Vec3::X); + let diameter = chord.distance(far_chord); + if diameter <= 1e-6 { + return; + } + + let arrows_outside = if params.ticks || params.arrow_len <= 1e-6 { + false + } else if diameter < 2.0 * params.arrow_len { + true + } else if diameter < params.text_width + 2.0 * params.arrow_len { + match params.dimatfit { + 0 | 1 => true, + 2 => false, + _ => params.text_width <= diameter, + } + } else { + false + }; + + let extension = if params.ticks { params.dimdle } else { 0.0 }; + let first = chord - axis * extension; + let second = far_chord + axis * extension; + let line_length = first.distance(second); + let mut left_end = line_length * 0.5; + let mut right_start = left_end; + if let Some((text_center, half_width, half_height)) = params.text_break { + let along = (text_center - first).dot(axis); + if along > 0.0 && along < line_length { + left_end = along; + right_start = along; + let perpendicular = (text_center - (first + axis * along)).length(); + if perpendicular < half_height + && along - half_width > 0.0 + && along + half_width < line_length + { + left_end = along - half_width; + right_start = along + half_width; + } + } + } + + let draw_inside_line = !arrows_outside || params.dimtofl; + if draw_inside_line && !suppress.dim1 && left_end > 1e-6 { + add_segment(&mut g.dim_lines, first, first + axis * left_end); + } + if draw_inside_line && !suppress.dim2 && line_length - right_start > 1e-6 { + add_segment( + &mut g.dim_lines, + first + axis * right_start, + second, + ); + } + if arrows_outside && !params.dimsoxd { + let stub = params.arrow_len * 2.0; + if !suppress.dim1 { + add_segment(&mut g.dim_lines, chord - axis * stub, chord); + } + if !suppress.dim2 { + add_segment(&mut g.dim_lines, far_chord, far_chord + axis * stub); + } + } + + if arrows_outside { + append_arrow(g, chord, -axis, arrow1); + append_arrow(g, far_chord, axis, arrow2); + } else { + append_arrow(g, chord, axis, arrow1); + append_arrow(g, far_chord, -axis, arrow2); + } + + let text_along = (params.text_position - chord).dot(axis); + if params.text_movement == 0 { + let (tip, suppressed) = if params.text_position.distance_squared(chord) + <= params.text_position.distance_squared(far_chord) + { + (chord, suppress.dim1) + } else { + (far_chord, suppress.dim2) + }; + if !suppressed && leader_length.abs() > 1e-6 { + let direction = normalized_or(params.text_position - tip, axis); + add_segment(&mut g.dim_lines, tip, tip + direction * leader_length.abs()); + } else if text_along < 0.0 && !suppress.dim1 { + add_segment(&mut g.dim_lines, chord, params.text_position); + } else if text_along > diameter && !suppress.dim2 { + add_segment(&mut g.dim_lines, far_chord, params.text_position); + } + } +} + /// Draw a center mark for radius/diameter dimensions. /// DIMCEN > 0 → small "+" of half-length |DIMCEN| at the centre. /// DIMCEN < 0 → small "+" *plus* four line segments extending from the @@ -2905,14 +4765,13 @@ fn two_line_angle_frame( arc_point: Vec3, ) -> Option<(Vec3, f32, f32)> { let (u, v) = (a2 - a1, b2 - b1); - let denominator = u.x * v.y - u.y * v.x; - // Near-parallel: the crossing runs away to infinity and the sweep with it. - if denominator.abs() <= 1e-9 * u.length().max(v.length()).max(1.0) { - return None; - } - let w = b1 - a1; - let t = (w.x * v.y - w.y * v.x) / denominator; - let vertex = a1 + u * t; + let (t, _) = cadkernel::geom2d::line_line( + [a1.x as f64, a1.y as f64], + [u.x as f64, u.y as f64], + [b1.x as f64, b1.y as f64], + [v.x as f64, v.y as f64], + )?; + let vertex = a1 + u * t as f32; // Two lines cross at four angles; the arc point picks one. Each line // contributes its direction and its reverse, so try the four pairs and keep @@ -2946,6 +4805,233 @@ 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) => { + let first_start = vec3_local(value.first_point); + let first_end = vec3_local(value.second_point); + let second_start = vec3_local(value.angle_vertex); + let second_end = vec3_local(value.definition_point); + let arc_point = vec3_local(value.dimension_arc); + let (vertex, start, end) = two_line_angle_frame( + first_start, + first_end, + second_start, + second_end, + arc_point, + )?; + (vertex, start, end, arc_point) + } + Dimension::Angular3Pt(value) => { + let vertex = vec3_local(value.angle_vertex); + let first = vec3_local(value.first_point); + let second = vec3_local(value.second_point); + let arc_point = vec3_local(value.definition_point); + let (vertex, start, end) = + 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); + (radius > 1.0e-6).then_some((vertex, start, end, radius)) +} + fn append_angular_dimension( g: &mut DimGeom, vertex: Vec3, @@ -2955,6 +5041,8 @@ fn append_angular_dimension( arrow1: &ArrowKind, arrow2: &ArrowKind, explicit_sweep: Option<(f32, f32)>, + params: DimLineParams, + suppress: SuppressFlags, ) { let radius = vertex.distance(arc_point); if radius <= 1e-6 { @@ -2969,8 +5057,30 @@ fn append_angular_dimension( let (start, mut end) = explicit_sweep.unwrap_or((measured_start, measured_end)); let dir1 = Vec3::new(start.cos(), start.sin(), 0.0); let dir2 = Vec3::new(end.cos(), end.sin(), 0.0); - add_segment(&mut g.ext_lines, first, vertex + dir1 * radius); - add_segment(&mut g.ext_lines, second, vertex + dir2 * radius); + let arc_start = vertex + dir1 * radius; + let arc_end = vertex + dir2 * radius; + let extension = |origin: Vec3, endpoint: Vec3, direction: Vec3| { + if params.dimfxlon { + ( + endpoint - direction * params.dimfxl.max(0.0), + endpoint + direction * params.dimexe, + ) + } else { + let toward_arc = normalized_or(endpoint - origin, direction); + ( + origin + toward_arc * params.dimexo, + endpoint + toward_arc * params.dimexe, + ) + } + }; + let (ext1_start, ext1_end) = extension(first, arc_start, dir1); + let (ext2_start, ext2_end) = extension(second, arc_end, dir2); + if !suppress.ext1 { + add_segment(&mut g.ext_lines, ext1_start, ext1_end); + } + if !suppress.ext2 { + add_segment(&mut g.ext_lines, ext2_start, ext2_end); + } let mut delta = end - start; // Wrap a negative sweep forwards, but leave a zero one alone: two rays that @@ -2987,32 +5097,158 @@ fn append_angular_dimension( delta = end - start; } - let steps = 32; - let mut arc_pts = Vec::with_capacity((steps + 1) as usize); - for i in 0..=steps { - let t = i as f32 / steps as f32; - let a = start + delta * t; - arc_pts.push(vertex + Vec3::new(a.cos() * radius, a.sin() * radius, 0.0)); + let arc_length = radius * delta.abs(); + let arrows_outside = if params.ticks || params.arrow_len <= 1.0e-6 { + false + } else if arc_length < params.arrow_len * 2.0 { + true + } else if arc_length < params.text_width + params.arrow_len * 2.0 { + match params.dimatfit { + 0 | 1 => true, + 2 => false, + _ => params.text_width <= arc_length, + } + } else { + false + }; + let draw_inside_line = !arrows_outside || params.dimtofl; + let arc_extension = if params.ticks { params.dimdle / radius } else { 0.0 }; + let direction = delta.signum(); + let draw_start = start - direction * arc_extension; + let draw_delta = delta + direction * arc_extension * 2.0; + let arc_pts = sample_angular_arc(vertex, radius, draw_start, draw_delta); + let steps = arc_pts.len().saturating_sub(1); + if draw_inside_line { + for index in 0..steps { + let t = (index as f32 + 0.5) / steps as f32; + if (t < 0.5 && suppress.dim1) || (t >= 0.5 && suppress.dim2) { + continue; + } + let a = arc_pts[index]; + let b = arc_pts[index + 1]; + let hidden_by_text = params.text_break.is_some_and(|(center, half_width, half_height)| { + let angle = draw_start + draw_delta * t; + let radial = Vec3::new(angle.cos(), angle.sin(), 0.0); + let tangent = Vec3::new(-angle.sin(), angle.cos(), 0.0); + let offset = center - (a + b) * 0.5; + offset.dot(tangent).abs() <= half_width + && offset.dot(radial).abs() <= half_height + }); + if !hidden_by_text { + add_segment(&mut g.dim_lines, a, b); + } + } } - add_polyline(&mut g.dim_lines, &arc_pts); - if arc_pts.len() >= 2 { + if arrows_outside && !params.dimsoxd { + let stub_angle = (params.arrow_len * 2.0 / radius).min(std::f32::consts::FRAC_PI_2); + if !suppress.dim1 { + append_sampled_arc( + &mut g.dim_lines, + vertex, + radius, + start - direction * stub_angle, + start, + ); + } + if !suppress.dim2 { + append_sampled_arc( + &mut g.dim_lines, + vertex, + radius, + end, + end + direction * stub_angle, + ); + } + } + + if let Some((text_center, half_width, _)) = params.text_break { + let text_angle = (text_center.y - vertex.y).atan2(text_center.x - vertex.x); + let into = (text_angle - start).rem_euclid(std::f32::consts::TAU); + if into > delta.abs() + 1.0e-6 { + let back_to_start = (start - text_angle).rem_euclid(std::f32::consts::TAU); + let forward_from_end = (text_angle - end).rem_euclid(std::f32::consts::TAU); + let text_gap = (half_width / radius).min(std::f32::consts::FRAC_PI_2); + if back_to_start <= forward_from_end && !suppress.dim1 { + append_sampled_arc( + &mut g.dim_lines, + vertex, + radius, + text_angle + text_gap, + start, + ); + } else if !suppress.dim2 { + append_sampled_arc( + &mut g.dim_lines, + vertex, + radius, + end, + text_angle - text_gap, + ); + } + } + } + + let start_tangent = Vec3::new(-start.sin(), start.cos(), 0.0) * direction; + let end_tangent = Vec3::new(-end.sin(), end.cos(), 0.0) * direction; + let draw_arrows = !arrows_outside || !params.dimsoxd; + if draw_arrows && !suppress.dim1 { append_arrow( g, - arc_pts[0], - normalized_or(arc_pts[1] - arc_pts[0], Vec3::X), + arc_start, + if arrows_outside { -start_tangent } else { start_tangent }, arrow1, ); - let n = arc_pts.len(); + } + if draw_arrows && !suppress.dim2 { append_arrow( g, - arc_pts[n - 1], - normalized_or(arc_pts[n - 2] - arc_pts[n - 1], Vec3::X), + arc_end, + if arrows_outside { end_tangent } else { -end_tangent }, arrow2, ); } } +fn append_sampled_arc( + lines: &mut Vec<[f32; 3]>, + vertex: Vec3, + radius: f32, + start: f32, + end: f32, +) { + for pair in sample_angular_arc(vertex, radius, start, end - start).windows(2) { + add_segment(lines, pair[0], pair[1]); + } +} + +fn sample_angular_arc(vertex: Vec3, radius: f32, start: f32, sweep: f32) -> Vec { + if sweep.abs() <= 1.0e-6 { + return vec![vertex + Vec3::new(start.cos() * radius, start.sin() * radius, 0.0)]; + } + let end = start + sweep; + let (from, to, reverse) = if sweep >= 0.0 { + (start, end, false) + } else { + (end, start, true) + }; + let mut points: Vec<_> = cadkernel::geom2d::tessellate::arc( + [vertex.x as f64, vertex.y as f64], + radius as f64, + from as f64, + to as f64, + vertex.z as f64, + cadkernel::geom2d::tessellate::DEFAULT_SEGMENTS_PER_RADIAN, + ) + .into_iter() + .map(|point| Vec3::new(point[0] as f32, point[1] as f32, point[2] as f32)) + .collect(); + if reverse { + points.reverse(); + } + points +} + fn dimension_snap_pts(dim: &Dimension) -> Vec<(glam::DVec3, SnapHint)> { let lv = |v: acadrust::types::Vector3| glam::DVec3::new(v.x, v.y, v.z); let node = |v: acadrust::types::Vector3| (lv(v), SnapHint::Node); @@ -3030,10 +5266,11 @@ fn dimension_snap_pts(dim: &Dimension) -> Vec<(glam::DVec3, SnapHint)> { Dimension::Radius(d) => vec![node(d.angle_vertex), node(d.definition_point)], Dimension::Diameter(d) => vec![node(d.angle_vertex), node(d.definition_point)], Dimension::Angular2Ln(d) => vec![ - node(d.angle_vertex), node(d.first_point), node(d.second_point), + node(d.angle_vertex), node(d.definition_point), + node(d.dimension_arc), ], Dimension::Angular3Pt(d) => vec![ node(d.angle_vertex), @@ -3042,7 +5279,6 @@ fn dimension_snap_pts(dim: &Dimension) -> Vec<(glam::DVec3, SnapHint)> { node(d.definition_point), ], Dimension::Ordinate(d) => vec![ - node(d.definition_point), node(d.feature_location), node(d.leader_endpoint), ], @@ -3127,7 +5363,9 @@ fn dimension_text_entity( document: &CadDocument, dim_scale: f64, ) -> Option { - let value = dimension_text_value(dim, style)?; + // Tolerances are emitted by `dimension_tolerance_entity` at their own + // height and alignment; keep the primary entity free of duplicate text. + let (value, _) = dimension_text_parts(dim, style)?; // Use f64 position directly to avoid f32 round-trip precision loss at large // coordinates (e.g. Turkish UTM ~4,000,000 m). tessellate() will apply // world_offset when rendering this synthetic entity. @@ -3189,6 +5427,11 @@ fn dimension_text_entity( return Some(EntityType::MText(mtext)); } + let value = if style.is_some_and(|style| style.dimtxtdirection) { + value.chars().rev().collect() + } else { + value + }; let mut text = Text::with_value(value, pos_f64) .with_height(text_height) .with_rotation(rotation); @@ -3242,11 +5485,14 @@ fn dimension_text_rotation(dim: &Dimension, style: Option<&DimStyle>) -> f64 { let dimtih = style.map(|s| s.dimtih).unwrap_or(false); let dimtoh = style.map(|s| s.dimtoh).unwrap_or(false); let dimjust = style.map(|s| s.dimjust).unwrap_or(0); + let outside = dimension_text_is_outside(dim, style); if base.text_rotation.abs() > 1e-9 { base.text_rotation + } else if matches!(dim, Dimension::Ordinate(_)) { + dimension_text_natural_rotation(dim) } else if base.horizontal_direction.abs() > 1e-9 { base.horizontal_direction - } else if dimtih || dimtoh { + } else if (outside && dimtoh) || (!outside && dimtih) { 0.0 } else { let mut r = dimension_text_natural_rotation(dim); @@ -3257,6 +5503,111 @@ fn dimension_text_rotation(dim: &Dimension, style: Option<&DimStyle>) -> f64 { } } +fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool { + let Some(style) = style else { + return false; + }; + if let Some((vertex, start, end, radius)) = angular_dimension_frame(dim) { + if dim.base().text_user_positioned { + let text = vec3_local(dim.base().text_middle_point); + let angle = (text.y - vertex.y).atan2(text.x - vertex.x); + return (angle - start).rem_euclid(std::f32::consts::TAU) + > end - start + 1.0e-6; + } + if style.dimtix { + return false; + } + let scale = if style.dimscale > 1e-9 { style.dimscale } else { 1.0 }; + let height = style.dimtxt * scale; + let gap = style.dimgap.abs() * scale; + let text_width = dimension_text_value(dim, Some(style)) + .map(|value| value.chars().count() as f64 * height * 0.6 + gap * 2.0) + .unwrap_or(0.0); + let arrow = style.dimasz * scale; + let span = radius as f64 * (end - start).abs() as f64; + let insufficient = text_width + arrow * 2.0 > span; + return insufficient + && match style.dimatfit { + 0 | 2 => true, + 1 | 3 => text_width > span, + _ => text_width > span, + }; + } + if let Dimension::Radius(radius) = dim { + let dx = radius.definition_point.x - radius.angle_vertex.x; + let dy = radius.definition_point.y - radius.angle_vertex.y; + let available = dx.hypot(dy); + if dim.base().text_user_positioned { + let text = dim.base().text_middle_point; + return (text.x - radius.angle_vertex.x) + .hypot(text.y - radius.angle_vertex.y) + > available + 1e-9; + } + if style.dimtix { + return false; + } + let scale = if style.dimscale > 1e-9 { style.dimscale } else { 1.0 }; + let height = style.dimtxt * scale; + let gap = style.dimgap.abs() * scale; + let text_width = dimension_text_value(dim, Some(style)) + .map(|value| value.chars().count() as f64 * height * 0.6 + gap * 2.0) + .unwrap_or(0.0); + let arrow = style.dimasz * scale; + let insufficient = text_width + arrow > available; + return insufficient + && match style.dimatfit { + 0 | 2 => true, + 1 | 3 => text_width > available, + _ => text_width > available, + }; + } + let (first, second, axis) = match dim { + Dimension::Linear(d) => ( + d.first_point, + d.second_point, + Vector3::new(d.rotation.cos(), d.rotation.sin(), 0.0), + ), + Dimension::Aligned(d) => { + let delta = d.second_point - d.first_point; + let length = (delta.x * delta.x + delta.y * delta.y).sqrt().max(1e-12); + (d.first_point, d.second_point, delta / length) + } + Dimension::Diameter(d) => { + let delta = d.definition_point - d.angle_vertex; + let length = (delta.x * delta.x + delta.y * delta.y).sqrt().max(1e-12); + (d.angle_vertex, d.definition_point, delta / length) + } + _ => return false, + }; + let first_axis = first.x * axis.x + first.y * axis.y; + let second_axis = second.x * axis.x + second.y * axis.y; + let lo = first_axis.min(second_axis); + let hi = first_axis.max(second_axis); + if dim.base().text_user_positioned { + let point = dim.base().text_middle_point; + let position = point.x * axis.x + point.y * axis.y; + return position < lo || position > hi; + } + if style.dimtix { + return false; + } + let scale = if style.dimscale > 1e-9 { style.dimscale } else { 1.0 }; + let height = style.dimtxt * scale; + let gap = style.dimgap.abs() * scale; + let text_width = dimension_text_value(dim, Some(style)) + .map(|value| value.chars().count() as f64 * height * 0.6 + gap * 2.0) + .unwrap_or(0.0); + let arrow = style.dimasz * scale; + let span = hi - lo; + let insufficient = text_width + arrow * 2.0 > span; + insufficient + && match style.dimatfit { + 0 | 2 => true, + 1 | 3 => text_width > span, + _ => text_width > span, + } +} + fn dimension_text_natural_rotation(dim: &Dimension) -> f64 { let angle = match dim { Dimension::Linear(d) => d.rotation, @@ -3265,6 +5616,23 @@ 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(_) | Dimension::Arc(_) => angular_dimension_frame(dim) + .map(|(_, start, end, _)| { + ((start + end) * 0.5 + std::f32::consts::FRAC_PI_2) as f64 + }) + .unwrap_or(0.0), + Dimension::Radius(d) => (d.definition_point.y - d.angle_vertex.y) + .atan2(d.definition_point.x - d.angle_vertex.x), + Dimension::Diameter(d) => (d.definition_point.y - d.angle_vertex.y) + .atan2(d.definition_point.x - d.angle_vertex.x), + Dimension::Ordinate(d) => { + let axis_rotation = -d.base.horizontal_direction; + if d.is_ordinate_type_x { + axis_rotation + std::f64::consts::FRAC_PI_2 + } else { + axis_rotation + } + } _ => 0.0, }; // Clamp to (-π/2, π/2] so text never appears upside-down. @@ -3314,7 +5682,7 @@ fn dimension_text_parts( // Build tolerance / limits suffix separately so the caller can render // it as its own Text entity at DIMTFAC × DIMTXT height. - let tolerance_suffix = build_tolerance_suffix(dim.measurement(), style, is_angular); + let tolerance_suffix = build_tolerance_suffix(dim, style, is_angular); let primary = apply_dimpost(&primary_raw, style); // Alternate units appended in brackets when DIMALT is on (linear only). @@ -3342,21 +5710,22 @@ fn dimension_text_parts( } fn build_tolerance_suffix( - measurement: f64, + dim: &Dimension, style: Option<&DimStyle>, is_angular: bool, ) -> Option { let s = style?; + let measurement = dim.measurement(); let dimtdec = s.dimtdec.max(0) as usize; let dimtzin = s.dimtzin; let fmt = |v: f64| -> String { let raw = format!("{:.*}", dimtdec, v); - apply_linear_zero_suppression(&raw, dimtzin) + swap_decimal_sep(&apply_linear_zero_suppression(&raw, dimtzin), s.dimdsep) }; if s.dimlim { let high = measurement + s.dimtp; let low = measurement - s.dimtm; - return Some(format!("{}/{}", fmt(high), fmt(low))); + return Some(format!("\\S{}^{};", fmt(high), fmt(low))); } if s.dimtol { let unit = if is_angular { "°" } else { "" }; @@ -3364,18 +5733,44 @@ fn build_tolerance_suffix( return Some(format!("±{}{}", fmt(s.dimtp), unit)); } if s.dimtp.abs() > 1e-12 || s.dimtm.abs() > 1e-12 { + let mut upper = fmt(s.dimtp); + let mut lower = fmt(s.dimtm); + let alignment = crate::entities::dim_override::int( + &dim.base().common.extended_data, + crate::entities::dim_override::DIMTALN, + ) + .unwrap_or(0); + if alignment == 0 { + align_tolerance_decimals(&mut upper, &mut lower, s.dimdsep); + } return Some(format!( - "+{}{} / -{}{}", - fmt(s.dimtp), - unit, - fmt(s.dimtm), - unit + "\\S+{}{}^-{}{};", + upper, unit, lower, unit )); } } None } +fn align_tolerance_decimals(upper: &mut String, lower: &mut String, separator: i16) { + let separator = separator as u8 as char; + let integer_width = |value: &str| { + value + .find(separator) + .or_else(|| value.find('.')) + .unwrap_or(value.len()) + }; + let upper_width = integer_width(upper); + let lower_width = integer_width(lower); + let target = upper_width.max(lower_width); + if upper_width < target { + upper.insert_str(0, &" ".repeat(target - upper_width)); + } + if lower_width < target { + lower.insert_str(0, &" ".repeat(target - lower_width)); + } +} + /// Build the bracketed alternate-units suffix when DIMALT is enabled. /// When DIMTOL is also on, the bracketed text includes the tolerance /// component formatted with DIMALTTD / DIMALTTZ. @@ -3384,20 +5779,38 @@ fn alternate_units_text(measurement: f64, style: Option<&DimStyle>) -> Option 1e-12; + // DIMALTMZF replaces the ordinary alternate-unit factor for sub-unit + // values; it is not an additional multiplier. + let mut v = if use_sub_units { + measurement * s.dimaltmzf + } else { + scaled + }; if s.dimaltrnd > 1e-12 { v = (v / s.dimaltrnd).round() * s.dimaltrnd; } let dec = s.dimaltd.max(0) as usize; - let raw = format_with_unit(v, s.dimaltu, dec, s.dimfrac, s.dimaltz); + let raw = format_with_unit(v, s.dimaltu, dec, s.dimfrac, s.dimaltz, true); let suppressed = apply_linear_zero_suppression(&raw, s.dimaltz); - let sep_swapped = swap_decimal_sep(&suppressed, s.dimdsep); + let mut sep_swapped = swap_decimal_sep(&suppressed, s.dimdsep); + if use_sub_units { + sep_swapped.push_str(&s.dimaltmzs); + } + let tolerance_factor = if use_sub_units { + s.dimaltmzf + } else { + s.dimaltf + }; // Alt-unit tolerance suffix using DIMALTTD / DIMALTTZ. let alt_value = if s.dimtol { let alttdec = s.dimalttd.max(0) as usize; let alttzin = s.dimalttz; let fmt = |x: f64| -> String { - let raw = format!("{:.*}", alttdec, x * s.dimaltf); + let raw = format!("{:.*}", alttdec, x * tolerance_factor); swap_decimal_sep(&apply_linear_zero_suppression(&raw, alttzin), s.dimdsep) }; if (s.dimtp - s.dimtm).abs() < 1e-12 && s.dimtp.abs() > 1e-12 { @@ -3411,7 +5824,7 @@ fn alternate_units_text(measurement: f64, style: Option<&DimStyle>) -> Option String { - let raw = format!("{:.*}", alttdec, x * s.dimaltf); + let raw = format!("{:.*}", alttdec, x * tolerance_factor); swap_decimal_sep(&apply_linear_zero_suppression(&raw, alttzin), s.dimdsep) }; format!( @@ -3445,7 +5858,7 @@ fn dimension_tolerance_entity( ) -> Option { let s = style?; let is_angular = matches!(dim, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)); - let tol = build_tolerance_suffix(dim.measurement(), style, is_angular)?; + let tol = build_tolerance_suffix(dim, style, is_angular)?; let dimtfac = if s.dimtfac.abs() < 1e-12 { 1.0 } else { @@ -3477,7 +5890,12 @@ fn dimension_tolerance_entity( // Approximate widths from glyph counts (~0.6 × cell size per char). let primary_w = primary_value_len as f64 * primary_height * 0.6; - let tol_w = tol.chars().count() as f64 * tol_height * 0.6; + let tol_visible_chars = tol + .strip_prefix("\\S") + .and_then(|value| value.strip_suffix(';')) + .map(|value| value.split('^').map(str::len).max().unwrap_or(0)) + .unwrap_or_else(|| tol.chars().count()); + let tol_w = tol_visible_chars as f64 * tol_height * 0.6; let gap = primary_height * 0.2; let dx_local = primary_w * 0.5 + tol_w * 0.5 + gap; let dy_local = match s.dimtolj { @@ -3492,6 +5910,16 @@ fn dimension_tolerance_entity( primary_insertion.y + dx_local * sr + dy_local * cr, primary_insertion.z, ); + if value_has_mtext_codes(&tol) { + let mut mtext = MText::with_value(tol, pos); + mtext.height = tol_height; + mtext.rotation = rot; + mtext.style = primary_style; + mtext.attachment_point = acadrust::entities::AttachmentPoint::MiddleCenter; + mtext.common = primary_common; + return Some(EntityType::MText(mtext)); + } + let mut t = Text::with_value(tol, pos) .with_height(tol_height) .with_rotation(rot); @@ -3518,23 +5946,43 @@ fn apply_dimpost(value: &str, style: Option<&DimStyle>) -> String { /// Format a linear measurement honouring DIMLFAC, DIMRND, DIMDEC, DIMZIN, DIMDSEP, DIMLUNIT. fn format_linear_value(measurement: f64, style: Option<&DimStyle>) -> String { - let (dec, zin, lfac, rnd, dsep, lunit, frac) = style + let (dec, zin, lfac, rnd, dsep, lunit, frac, sub_factor, sub_suffix) = style .map(|s| { ( - s.dimdec, s.dimzin, s.dimlfac, s.dimrnd, s.dimdsep, s.dimlunit, s.dimfrac, + s.dimdec, + s.dimzin, + s.dimlfac, + s.dimrnd, + s.dimdsep, + s.dimlunit, + s.dimfrac, + s.dimmzf, + s.dimmzs.as_str(), ) }) - .unwrap_or((4, 8, 1.0, 0.0, 46, 2, 0)); + .unwrap_or((4, 8, 1.0, 0.0, 46, 2, 0, 1.0, "")); let lfac = if lfac.abs() < 1e-12 { 1.0 } else { lfac }; - let mut v = measurement * lfac; + let scaled = measurement * lfac; + let use_sub_units = zin & 4 != 0 && scaled.abs() < 1.0 && sub_factor.abs() > 1e-12; + // For values below one unit, DIMMZF replaces DIMLFAC; applying it after + // DIMLFAC multiplies both factors and reports the wrong sub-unit value. + let mut v = if use_sub_units { + measurement * sub_factor + } else { + scaled + }; if rnd > 1e-12 { v = (v / rnd).round() * rnd; } let dec = dec.max(0) as usize; - let raw = format_with_unit(v, lunit, dec, frac, zin); + let raw = format_with_unit(v, lunit, dec, frac, zin, false); let suppressed = apply_linear_zero_suppression(&raw, zin); - swap_decimal_sep(&suppressed, dsep) + let mut formatted = swap_decimal_sep(&suppressed, dsep); + if use_sub_units { + formatted.push_str(sub_suffix); + } + formatted } /// Dispatch on DIMLUNIT / DIMALTU. @@ -3543,15 +5991,23 @@ fn format_linear_value(measurement: f64, style: Option<&DimStyle>) -> String { /// 3 = Engineering (feet + decimal inches; 1 unit = 1 inch) /// 4 = Architectural (feet + fractional inches) /// 5 = Fractional (integer + fractional inches) -/// 6 = Windows desktop → falls back to Decimal -/// `dimfrac` controls denominator power for arch/fractional output (0/1/2); -/// rendered inline as "n/d" (stacked glyphs require MText support). -fn format_with_unit(value: f64, unit: i16, dec: usize, dimfrac: i16, zin: i16) -> String { +/// For alternate units, 4/5 are stacked and 6/7 are unstacked architectural +/// and fractional forms. For primary units DIMFRAC selects the stack form. +fn format_with_unit( + value: f64, + unit: i16, + dec: usize, + dimfrac: i16, + zin: i16, + alternate: bool, +) -> String { match unit { 1 => format!("{:.*e}", dec, value), 3 => format_engineering(value, dec), - 4 => format_architectural(value, dimfrac, zin), - 5 => format_fractional(value, dimfrac), + 4 => format_architectural(value, dec, if alternate { 0 } else { dimfrac }, zin), + 5 => format_fractional(value, dec, if alternate { 0 } else { dimfrac }), + 6 if alternate => format_architectural(value, dec, 2, zin), + 7 if alternate => format_fractional(value, dec, 2), _ => format!("{:.*}", dec, value), } } @@ -3564,8 +6020,8 @@ fn format_engineering(inches: f64, dec: usize) -> String { format!("{}{:.0}'-{:.*}\"", sign, feet, dec, rem_in) } -fn format_architectural(inches: f64, dimfrac: i16, zin: i16) -> String { - let denom = arch_denom(dimfrac); +fn format_architectural(inches: f64, precision: usize, dimfrac: i16, zin: i16) -> String { + let denom = fractional_denominator(precision); // Round to the nearest 1/denom inch *first*, in integer ticks, so a fraction // that rounds up to a whole inch carries into the inches — and on into the // feet — instead of printing an un-reduced "11 1/1". A 3ft object that @@ -3576,7 +6032,7 @@ fn format_architectural(inches: f64, dimfrac: i16, zin: i16) -> String { let feet = ticks / per_foot; let rem = ticks % per_foot; let whole = rem / denom; - let frac_str = reduce_fraction(rem % denom, denom); + let frac_str = format_fraction_component(&reduce_fraction(rem % denom, denom), dimfrac); // DIMZIN feet/inch suppression: // 0 suppress zero feet & zero inches, 1 include both, @@ -3612,13 +6068,13 @@ fn format_architectural(inches: f64, dimfrac: i16, zin: i16) -> String { format!("{}{}", sign, body) } -fn format_fractional(value: f64, dimfrac: i16) -> String { - let denom = arch_denom(dimfrac); +fn format_fractional(value: f64, precision: usize, dimfrac: i16) -> String { + let denom = fractional_denominator(precision); // Round on the fraction grid first (same carry reasoning as architectural). let ticks = (value.abs() * denom as f64).round() as u64; let sign = if value < 0.0 && ticks != 0 { "-" } else { "" }; let whole = ticks / denom; - let frac_str = reduce_fraction(ticks % denom, denom); + let frac_str = format_fraction_component(&reduce_fraction(ticks % denom, denom), dimfrac); if frac_str.is_empty() { format!("{}{}", sign, whole) } else if whole == 0 { @@ -3628,12 +6084,18 @@ fn format_fractional(value: f64, dimfrac: i16) -> String { } } -/// Power-of-two denominator for architectural / fractional output. DIMFRAC is -/// the exponent-like fraction-format knob; clamp it to a readable -/// range (16ths … 1024ths). -fn arch_denom(dimfrac: i16) -> u64 { - let exp = (dimfrac.clamp(0, 8) as u32).max(2) + 2; // 4..=10 → 16..=1024 - 1u64 << exp +/// Fraction precision maps 0..8 to whole units through 1/256. DIMFRAC controls +/// only the visual stack form; it must not change the numeric denominator. +fn fractional_denominator(precision: usize) -> u64 { + 1u64 << precision.min(8) +} + +fn format_fraction_component(value: &str, dimfrac: i16) -> String { + if value.is_empty() || dimfrac == 2 { + return value.to_string(); + } + let separator = if dimfrac == 1 { '#' } else { '/' }; + format!("\\S{};", value.replacen('/', &separator.to_string(), 1)) } /// Reduce a power-of-two fraction numer/denom to display form. Empty for a zero @@ -3655,54 +6117,62 @@ fn reduce_fraction(mut n: u64, mut d: u64) -> String { } } -/// Format an angular measurement (input in degrees as Dimension::measurement -/// returns for angular variants) honouring DIMAUNIT, DIMADEC, DIMAZIN. +/// Format an angular measurement in degrees using the angular style settings. fn format_angular_value(measurement_deg: f64, style: Option<&DimStyle>) -> String { - let (aunit, adec, azin) = style - .map(|s| (s.dimaunit, s.dimadec, s.dimazin)) - .unwrap_or((0, 2, 0)); - let adec = adec.max(0) as usize; + let (aunit, adec, azin, decimal_separator) = style + .map(|s| { + let precision = if s.dimadec < 0 { s.dimdec } else { s.dimadec }; + (s.dimaunit, precision, s.dimazin, s.dimdsep) + }) + .unwrap_or((0, 2, 0, b'.' as i16)); + let adec = adec.clamp(0, 8) as usize; match aunit { // 1 = Degrees / Minutes / Seconds - 1 => format_dms(measurement_deg, adec, azin), + 1 => format_dms(measurement_deg, adec, azin, decimal_separator), // 2 = Gradians 2 => { let g = measurement_deg / 0.9; let raw = format!("{:.*}", adec, g); - format!("{}g", apply_angular_zero_suppression(&raw, azin)) + format!( + "{}g", + swap_decimal_sep(&apply_angular_zero_suppression(&raw, azin), decimal_separator) + ) } // 3 = Radians 3 => { let r = measurement_deg.to_radians(); let raw = format!("{:.*}", adec, r); - format!("{}r", apply_angular_zero_suppression(&raw, azin)) + format!( + "{}r", + swap_decimal_sep(&apply_angular_zero_suppression(&raw, azin), decimal_separator) + ) } // 0 or unknown = Decimal Degrees _ => { let raw = format!("{:.*}", adec, measurement_deg); - format!("{}°", apply_angular_zero_suppression(&raw, azin)) + format!( + "{}°", + swap_decimal_sep(&apply_angular_zero_suppression(&raw, azin), decimal_separator) + ) } } } -fn format_dms(deg: f64, sec_dec: usize, azin: i16) -> String { +fn format_dms(deg: f64, sec_dec: usize, azin: i16, decimal_separator: i16) -> String { let sign = if deg < 0.0 { "-" } else { "" }; - let abs = deg.abs(); - let d = abs.floor(); - let m_full = (abs - d) * 60.0; - let m = m_full.floor(); - let s = (m_full - m) * 60.0; + let scale = 10_u64.pow(sec_dec.min(8) as u32) as f64; + let total_ticks = (deg.abs() * 3600.0 * scale).round(); + let d = (total_ticks / (3600.0 * scale)).floor(); + let remaining = total_ticks - d * 3600.0 * scale; + let m = (remaining / (60.0 * scale)).floor(); + let s = (remaining - m * 60.0 * scale) / scale; let s_str = format!("{:.*}", sec_dec, s); - let mut out = format!("{}{:.0}°{:.0}'{}\"", sign, d, m, s_str); - if azin & 4 != 0 { - // suppress 0° / 0' parts - if d == 0.0 { - out = out.trim_start_matches('0').to_string(); - out = out.replacen("°", "", 1); - } - } - out + let s_str = swap_decimal_sep( + &apply_angular_zero_suppression(&s_str, azin), + decimal_separator, + ); + format!("{}{:.0}°{:.0}'{}\"", sign, d, m, s_str) } /// Apply DIMZIN bit flags to a formatted linear value. @@ -3766,26 +6236,6 @@ fn swap_decimal_sep(s: &str, dsep_code: i16) -> String { s.replace('.', &ch.to_string()) } -fn dimension_text_position(dim: &Dimension) -> Vec3 { - let lv = |v| vec3_local(v); - let base = dim.base(); - let pos = lv(base.text_middle_point); - if pos.length_squared() > 1e-8 { - return pos; - } - match dim { - Dimension::Aligned(d) => (lv(d.first_point) + lv(d.second_point)) * 0.5, - Dimension::Linear(d) => (lv(d.first_point) + lv(d.second_point)) * 0.5, - Dimension::Radius(d) => (lv(d.angle_vertex) + lv(d.definition_point)) * 0.5, - Dimension::Diameter(d) => (lv(d.angle_vertex) + lv(d.definition_point)) * 0.5, - Dimension::Angular2Ln(d) => lv(d.dimension_arc), - Dimension::Angular3Pt(d) => lv(d.definition_point), - Dimension::Ordinate(d) => lv(d.leader_endpoint), - Dimension::Arc(d) => lv(d.definition_point), - Dimension::LargeRadial(d) => lv(d.jog_point), - } -} - fn vec3_local(v: Vector3) -> Vec3 { Vec3::new(v.x as f32, v.y as f32, v.z as f32) } @@ -3805,6 +6255,7 @@ fn text_on_dim_line( text_w: f64, arrow: f64, dimtix: bool, + dimatfit: i16, tad: i16, ) -> Vector3 { // The perpendicular "up" side: the perpendicular of the dimension line @@ -3844,13 +6295,20 @@ fn text_on_dim_line( 2 | 4 => t2, _ => (t1 + t2) * 0.5, }; - // DIMATFIT / DIMTIX fit: when centred text can't fit between the extension - // lines, slide it just past the far one (text-outside placement) unless - // DIMTIX forces it to stay inside. + // DIMATFIT / DIMTIX fit: move text according to the selected priority when + // the combined text-and-arrow envelope cannot fit. if dimjust == 0 && !dimtix && text_w > 0.0 { let lo = t1.min(t2); let hi = t1.max(t2); - if text_w > (hi - lo) - 2.0 * arrow { + let span = hi - lo; + let insufficient = text_w + 2.0 * arrow > span; + let text_outside = insufficient + && match dimatfit { + 0 | 2 => true, + 1 | 3 => text_w > span, + _ => text_w > span, + }; + if text_outside { along = hi + arrow + text_w * 0.5; } } @@ -3877,10 +6335,11 @@ fn dimension_text_pos_f64( let dimtad = style.map(|s| s.dimtad).unwrap_or(1); // DIMGAP scales with DIMSCALE just like DIMTXT, so the text-to-line gap // stays consistent when DIMSCALE != 1. - let dimgap = style.map(|s| s.dimgap).unwrap_or(0.0) * dim_scale; + let dimgap = style.map(|s| s.dimgap.abs()).unwrap_or(0.0) * dim_scale; let dimjust = style.map(|s| s.dimjust).unwrap_or(0); // DIMTIX forces the text to stay between the extension lines. let dimtix = style.map(|s| s.dimtix).unwrap_or(false); + let dimatfit = style.map(|s| s.dimatfit).unwrap_or(3); // DIMTVP vertical-position multiplier (units of dimtxt). Only honoured when // DIMTAD == 0; offsets text perpendicular to the dim line. let dimtvp = style.map(|s| s.dimtvp).unwrap_or(0.0); @@ -3920,6 +6379,7 @@ fn dimension_text_pos_f64( text_w, arrow, dimtix, + dimatfit, dimtad, ) } @@ -3938,30 +6398,106 @@ fn dimension_text_pos_f64( text_w, arrow, dimtix, + dimatfit, dimtad, ) } + Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => { + let Some((vertex, start, end, radius)) = angular_dimension_frame(dim) else { + return base.text_middle_point; + }; + let span = radius as f64 * (end - start).abs() as f64; + let insufficient = text_w + arrow * 2.0 > span; + let move_outside = !dimtix + && insufficient + && match dimatfit { + 0 | 2 => true, + 1 | 3 => text_w > span, + _ => text_w > span, + }; + let angle = if move_outside { + end as f64 + (text_w * 0.5 + dimgap + arrow) / (radius as f64).max(1.0e-12) + } else { + ((start + end) * 0.5) as f64 + }; + let radial_offset = if dimtad == 4 { -perp_off } else { perp_off }; + let text_radius = (radius as f64 + radial_offset).max(0.0); + Vector3::new( + vertex.x as f64 + angle.cos() * text_radius, + vertex.y as f64 + angle.sin() * text_radius, + vertex.z as f64, + ) + } + Dimension::Radius(d) => { + let dx = d.definition_point.x - d.angle_vertex.x; + let dy = d.definition_point.y - d.angle_vertex.y; + let radius = dx.hypot(dy).max(1e-12); + let ux = dx / radius; + let uy = dy / radius; + let outside = dimension_text_is_outside(dim, style); + let (mut x, mut y) = if outside { + let distance = arrow + text_w * 0.5 + dimgap; + ( + d.definition_point.x + ux * distance, + d.definition_point.y + uy * distance, + ) + } else { + ( + d.angle_vertex.x + ux * radius * 0.5, + d.angle_vertex.y + uy * radius * 0.5, + ) + }; + if dimtad != 0 { + let sign = if dimtad == 4 { -1.0 } else { 1.0 }; + x += -uy * perp_off * sign; + y += ux * perp_off * sign; + } + Vector3::new(x, y, d.definition_point.z) + } + Dimension::Diameter(d) => { + let dx = d.definition_point.x - d.angle_vertex.x; + let dy = d.definition_point.y - d.angle_vertex.y; + let len = (dx * dx + dy * dy).sqrt().max(1e-12); + text_on_dim_line( + d.angle_vertex, + d.definition_point, + d.angle_vertex, + dx / len, + dy / len, + dimjust, + perp_off, + text_w, + arrow, + dimtix, + dimatfit, + dimtad, + ) + } + Dimension::Ordinate(d) => { + let (x_axis, y_axis) = d.local_axes(); + let text_axis = if d.is_ordinate_type_x { y_axis } else { x_axis }; + let perpendicular = if d.is_ordinate_type_x { x_axis } else { y_axis }; + let delta = d.leader_endpoint - d.feature_location; + let direction_sign = if delta.dot(&text_axis) < 0.0 { -1.0 } else { 1.0 }; + let perpendicular_sign = if delta.dot(&perpendicular) < 0.0 { + -1.0 + } else { + 1.0 + }; + let vertical = match dimtad { + 0 => dimtvp * text_height, + 4 => -perp_off, + _ => perp_off, + }; + d.leader_endpoint + + text_axis * (direction_sign * (text_w * 0.5 + dimgap)) + + perpendicular * (perpendicular_sign * vertical) + } _ => { - // Non-linear (radius / diameter / angular / ordinate): lift the - // natural mid point straight up by the style offset. A user-dragged - // text point is already returned by the `use_saved` gate above, so - // we must NOT short-circuit on a merely-nonzero text_middle_point - // here — that would ignore the style placement for auto-placed dims - // and make a re-style a no-op. (#181) + // Auto-placed text follows the style offset. let mid = match dim { - Dimension::Radius(d) => Vector3::new( - (d.angle_vertex.x + d.definition_point.x) * 0.5, - (d.angle_vertex.y + d.definition_point.y) * 0.5, - (d.angle_vertex.z + d.definition_point.z) * 0.5, - ), - Dimension::Diameter(d) => Vector3::new( - (d.angle_vertex.x + d.definition_point.x) * 0.5, - (d.angle_vertex.y + d.definition_point.y) * 0.5, - (d.angle_vertex.z + d.definition_point.z) * 0.5, - ), Dimension::Angular2Ln(d) => d.dimension_arc, Dimension::Angular3Pt(d) => d.definition_point, - Dimension::Ordinate(d) => d.leader_endpoint, Dimension::Arc(d) => d.definition_point, Dimension::LargeRadial(d) => d.jog_point, _ => base.text_middle_point, @@ -4014,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 { + 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, @@ -4053,11 +6628,11 @@ mod dimtad_tests { fn above_is_up_regardless_of_direction() { let (first, second, defpt) = (v(0.0, 10.0), v(20.0, 10.0), v(0.0, 0.0)); let perp = 5.0; - let fwd = text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 1); - let rev = text_on_dim_line(first, second, defpt, -1.0, 0.0, 0, perp, 0.0, 1.0, false, 1); + let fwd = text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 3, 1); + let rev = text_on_dim_line(first, second, defpt, -1.0, 0.0, 0, perp, 0.0, 1.0, false, 3, 1); assert!(fwd.y > 0.0, "above must be +Y, got {}", fwd.y); assert!(rev.y > 0.0, "above must be +Y even reversed, got {}", rev.y); - let below = text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 4); + let below = text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 3, 4); assert!(below.y < 0.0, "below must be -Y, got {}", below.y); } @@ -4078,6 +6653,7 @@ mod dimtad_tests { 0.0, 1.0, false, + 3, 2, ); assert!( @@ -4097,6 +6673,7 @@ mod dimtad_tests { 0.0, 1.0, false, + 3, 2, ); assert!( @@ -4116,31 +6693,30 @@ mod arch_format_tests { // for the fraction that reduced to 1/1 and leaked out as a bare "1".) #[test] fn arch_carries_fraction_up_to_feet() { - assert_eq!(format_architectural(35.99, 0, 1), "3'-0\""); - assert_eq!(format_architectural(36.0, 0, 1), "3'-0\""); + assert_eq!(format_architectural(35.99, 4, 2, 1), "3'-0\""); + assert_eq!(format_architectural(36.0, 4, 2, 1), "3'-0\""); // An exact 15/16 must still render as a fraction, no spurious carry. - assert_eq!(format_architectural(35.9375, 0, 1), "2'-11 15/16\""); + assert_eq!(format_architectural(35.9375, 4, 2, 1), "2'-11 15/16\""); } // Carry that stops at inches (11.999" → 12" → 1'-0", not "0'-11 1""). #[test] fn arch_carries_fraction_up_to_inches() { - assert_eq!(format_architectural(11.999, 0, 1), "1'-0\""); + assert_eq!(format_architectural(11.999, 4, 2, 1), "1'-0\""); } #[test] fn arch_normal_values_unchanged() { - assert_eq!(format_architectural(30.5, 0, 1), "2'-6 1/2\""); - assert_eq!(format_architectural(0.0, 0, 1), "0'-0\""); - assert_eq!(format_architectural(-30.25, 0, 1), "-2'-6 1/4\""); + assert_eq!(format_architectural(30.5, 4, 2, 1), "2'-6 1/2\""); + assert_eq!(format_architectural(0.0, 4, 2, 1), "0'-0\""); + assert_eq!(format_architectural(-30.25, 4, 2, 1), "-2'-6 1/4\""); } // Same carry bug lived in the plain fractional formatter. #[test] fn fractional_carries_up() { - assert_eq!(format_fractional(35.9999, 0), "36"); - assert_eq!(format_fractional(11.999, 0), "12"); - assert_eq!(format_fractional(6.5, 0), "6 1/2"); + assert_eq!(format_fractional(35.9999, 4, 2), "36"); + assert_eq!(format_fractional(11.999, 4, 2), "12"); + assert_eq!(format_fractional(6.5, 4, 2), "6 1/2"); } } - diff --git a/src/entities/leader.rs b/src/entities/leader.rs index 53525918..58937cda 100644 --- a/src/entities/leader.rs +++ b/src/entities/leader.rs @@ -676,6 +676,7 @@ impl LeaderTess for Leader { return WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -855,6 +856,7 @@ impl LeaderTess for Leader { WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index e51bc8bb..77a18532 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -16,6 +16,15 @@ use crate::scene::model::wire_model::TangentGeom; const TAU: f64 = std::f64::consts::TAU; const REVCLOUD_BULGE: f64 = 0.5; const MAX_REVCLOUD_VERTICES: usize = 100_000; +const WIDTH_EPSILON: f64 = 1.0e-9; + +fn effective_width(width: f64, constant_width: f64) -> f64 { + if width > WIDTH_EPSILON { + width + } else { + constant_width + } +} /// Midpoint position on an arc segment defined by its bulge. fn arc_midpoint(p0: [f64; 2], p1: [f64; 2], bulge: f64) -> [f64; 2] { @@ -251,7 +260,7 @@ fn thick_wide_band( } } -fn to_render(pline: &LwPolyline) -> RenderEntity { +fn to_render(pline: &LwPolyline, fill_mode: bool) -> RenderEntity { let verts = &pline.vertices; if verts.is_empty() { return RenderEntity { @@ -268,9 +277,6 @@ fn to_render(pline: &LwPolyline) -> RenderEntity { let normal = (pline.normal.x, pline.normal.y, pline.normal.z); let count = verts.len(); let seg_count = if pline.is_closed { count } else { count - 1 }; - let mut tangents: Vec = Vec::new(); - let mut key_verts: Vec<[f64; 3]> = Vec::new(); - // Convert OCS (x, y, elevation) to a WCS point. let to_wcs = |x: f64, y: f64| -> (f64, f64, f64) { crate::scene::view::transform::ocs_point_to_wcs((x, y, elev), normal) @@ -280,6 +286,44 @@ fn to_render(pline: &LwPolyline) -> RenderEntity { [wx, wy, wz] }; + let band_verts = band_verts(pline); + if !fill_mode { + let mut boundary = crate::entities::common::wide_band_outline( + &band_verts, + pline.is_closed, + !pline.plinegen, + &to_wcs, + ); + if !boundary.points.is_empty() { + if pline.thickness.abs() > 1e-10 { + boundary = crate::entities::common::extrude_wide_band_outline( + boundary, + [ + pline.thickness * normal.0, + pline.thickness * normal.1, + pline.thickness * normal.2, + ], + ); + } + let (tangent_geoms, key_vertices) = centerline_metadata(pline, &to_wcs); + return RenderEntity { + pick_tris: Vec::new(), + object: RenderObject::BoundaryLines { + points: boundary.points, + stations: boundary.stations, + point_segments: boundary.point_segments, + station_pieces: boundary.station_pieces, + source_length: boundary.source_length, + plinegen: pline.plinegen, + }, + snap_pts: vec![], + tangent_geoms, + key_vertices, + fill_tris: vec![], + }; + } + } + if pline.thickness.abs() > 1e-10 { let mut path: Vec<[f64; 3]> = Vec::new(); let mut kv: Vec<[f64; 3]> = Vec::new(); @@ -341,7 +385,7 @@ fn to_render(pline: &LwPolyline) -> RenderEntity { // A wide polyline whose per-vertex widths VARY renders a smooth taper — // handled here, before the PLINEGEN split, so both cases get it. A // uniform-width polyline falls through to the constant-band paths below. - if let Some(band_verts) = tapered_band_verts(pline) { + if tapered_band_verts(&band_verts).is_some() { let mut kv: Vec<[f64; 3]> = Vec::new(); let mut tgs: Vec = Vec::new(); for i in 0..seg_count { @@ -370,8 +414,11 @@ fn to_render(pline: &LwPolyline) -> RenderEntity { }); } } - let (pts, widths) = - crate::entities::common::tapered_band_points(&band_verts, pline.is_closed, &to_wcs); + let (pts, widths) = crate::entities::common::tapered_band_points( + &band_verts, + pline.is_closed, + &to_wcs, + ); let (fill_origin, fills) = wide_fills(pline); return RenderEntity { pick_tris: crate::entities::common::wide_band_tris(fill_origin, &fills), @@ -444,36 +491,7 @@ fn to_render(pline: &LwPolyline) -> RenderEntity { }; } - for i in 0..seg_count { - let v0 = &verts[i]; - let v1 = &verts[(i + 1) % count]; - let p0 = to_pt(v0); - let p1 = to_pt(v1); - let bulge = v0.bulge; - - if bulge.abs() < 1e-9 { - tangents.push(TangentGeom::Line { - p1: [p0[0] as f32, p0[1] as f32, p0[2] as f32], - p2: [p1[0] as f32, p1[1] as f32, p1[2] as f32], - }); - } else if let Some(arc) = crate::entities::common::BulgeArc::from_bulge( - [v0.location.x, v0.location.y], - [v1.location.x, v1.location.y], - bulge as f64, - ) { - let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]); - tangents.push(TangentGeom::Circle { - center: [wcx as f32, wcy as f32, wcz as f32], - radius: arc.radius as f32, - }); - } - - if i == 0 { - key_verts.push([p0[0], p0[1], p0[2]]); - } - key_verts.push([p1[0], p1[1], p1[2]]); - } - + let (tangents, key_verts) = centerline_metadata(pline, &to_wcs); let (fill_origin, fills) = wide_fills(pline); RenderEntity { pick_tris: crate::entities::common::wide_band_tris(fill_origin, &fills), @@ -491,20 +509,23 @@ fn to_render(pline: &LwPolyline) -> RenderEntity { } } -/// The per-vertex `(location, bulge, start_width, end_width)` band description -/// for a wide LwPolyline whose width VARIES — `None` when the width is uniform -/// (a constant band) so the caller keeps the cheaper constant-width path. -fn tapered_band_verts(pline: &LwPolyline) -> Option> { +/// Effective segment widths for an LwPolyline band. +fn band_verts(pline: &LwPolyline) -> Vec<([f64; 2], f64, f64, f64)> { let c = pline.constant_width; - let band: Vec<([f64; 2], f64, f64, f64)> = pline + pline .vertices .iter() .map(|v| { - let sw = if v.start_width > 1e-9 { v.start_width } else { c }; - let ew = if v.end_width > 1e-9 { v.end_width } else { c }; + let sw = effective_width(v.start_width, c); + let ew = effective_width(v.end_width, c); ([v.location.x, v.location.y], v.bulge, sw, ew) }) - .collect(); + .collect() +} + +fn tapered_band_verts( + band: &[([f64; 2], f64, f64, f64)], +) -> Option<&[([f64; 2], f64, f64, f64)]> { let w0 = band.first().map_or(0.0, |v| v.2); let varies = band .iter() @@ -517,6 +538,47 @@ fn tapered_band_verts(pline: &LwPolyline) -> Option (f64, f64, f64), +) -> (Vec, Vec<[f64; 3]>) { + let count = pline.vertices.len(); + let segment_count = if pline.is_closed { + count + } else { + count.saturating_sub(1) + }; + let mut tangents = Vec::with_capacity(segment_count); + let mut key_vertices = Vec::with_capacity(segment_count + 1); + for index in 0..segment_count { + let start = &pline.vertices[index]; + let end = &pline.vertices[(index + 1) % count]; + let p0 = to_wcs(start.location.x, start.location.y); + let p1 = to_wcs(end.location.x, end.location.y); + if start.bulge.abs() < 1e-9 { + tangents.push(TangentGeom::Line { + p1: [p0.0 as f32, p0.1 as f32, p0.2 as f32], + p2: [p1.0 as f32, p1.1 as f32, p1.2 as f32], + }); + } else if let Some(arc) = crate::entities::common::BulgeArc::from_bulge( + [start.location.x, start.location.y], + [end.location.x, end.location.y], + start.bulge, + ) { + let center = to_wcs(arc.center[0], arc.center[1]); + tangents.push(TangentGeom::Circle { + center: [center.0 as f32, center.1 as f32, center.2 as f32], + radius: arc.radius as f32, + }); + } + if index == 0 { + key_vertices.push([p0.0, p0.1, p0.2]); + } + key_vertices.push([p1.0, p1.1, p1.2]); + } + (tangents, key_vertices) +} + /// Split at vertex `idx`: a closed polyline re-opens there (one piece); an /// open one splits into two (interior vertices only). `None` when invalid. pub(crate) fn break_at_vertex( @@ -852,8 +914,12 @@ fn properties(pline: &LwPolyline) -> Vec { let v = pline.vertices.get(vi); let vx = v.map_or(0.0, |v| v.location.x); let vy = v.map_or(0.0, |v| v.location.y); - let start_w = v.map_or(0.0, |v| v.start_width); - let end_w = v.map_or(0.0, |v| v.end_width); + let start_w = v.map_or(0.0, |v| { + effective_width(v.start_width, pline.constant_width) + }); + let end_w = v.map_or(0.0, |v| { + effective_width(v.end_width, pline.constant_width) + }); let mp = ::mass_props(pline); let cloud_arc_length = revision_cloud_arc_length(pline); let vertex_label = if n == 0 { @@ -964,7 +1030,7 @@ fn apply_geom_prop(pline: &mut LwPolyline, field: &str, value: &str) { }; match field { "elevation" => pline.elevation = v, - "global_width" => pline.constant_width = v, + "global_width" if v.is_finite() && v >= 0.0 => pline.constant_width = v, "vertex_x" => { if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.location.x = v; @@ -975,12 +1041,12 @@ fn apply_geom_prop(pline: &mut LwPolyline, field: &str, value: &str) { vtx.location.y = v; } } - "start_width" => { + "start_width" if v.is_finite() && v >= 0.0 => { if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.start_width = v; } } - "end_width" => { + "end_width" if v.is_finite() && v >= 0.0 => { if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.end_width = v; } @@ -1079,8 +1145,8 @@ fn apply_transform(pline: &mut LwPolyline, t: &EntityTransform) { } impl RenderConvertible for LwPolyline { - fn to_render(&self, _document: &acadrust::CadDocument) -> Option { - Some(to_render(self)) + fn to_render(&self, document: &acadrust::CadDocument) -> Option { + Some(to_render(self, document.header.fill_mode)) } } @@ -1201,16 +1267,8 @@ impl crate::entities::traits::Grippable for LwPolyline { new_v.location.x = midpoint[0]; new_v.location.y = midpoint[1]; new_v.vertex_id = 0; - let effective_start = if v0.start_width > 1e-9 { - v0.start_width - } else { - self.constant_width - }; - let effective_end = if v0.end_width > 1e-9 { - v0.end_width - } else { - self.constant_width - }; + let effective_start = effective_width(v0.start_width, self.constant_width); + let effective_end = effective_width(v0.end_width, self.constant_width); let middle_width = (effective_start + effective_end) * 0.5; self.vertices[i0].end_width = middle_width; new_v.start_width = middle_width; @@ -1271,7 +1329,6 @@ pub(crate) fn wide_fills(pl: &acadrust::entities::LwPolyline) -> ([f64; 2], Vec< // Feeding the stored width in whole draws every wide polyline twice as wide // as the file asks for, which on a donut (a closed 2-vertex bulge-1 polyline) // shows up as a disc 1.5× its real radius. - let hw_const = pl.constant_width as f32 * 0.5; let verts = &pl.vertices; let n = verts.len(); if n < 2 { @@ -1283,16 +1340,8 @@ pub(crate) fn wide_fills(pl: &acadrust::entities::LwPolyline) -> ([f64; 2], Vec< for i in 0..seg_count { let v0 = &verts[i]; let v1 = &verts[(i + 1) % n]; - let hw0 = if v0.start_width > 1e-9 { - v0.start_width as f32 * 0.5 - } else { - hw_const - }; - let hw1 = if v0.end_width > 1e-9 { - v0.end_width as f32 * 0.5 - } else { - hw_const - }; + let hw0 = effective_width(v0.start_width, pl.constant_width) as f32 * 0.5; + let hw1 = effective_width(v0.end_width, pl.constant_width) as f32 * 0.5; if hw0 < 1e-6 && hw1 < 1e-6 { continue; } diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index 5c6a46eb..657dc978 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -5,7 +5,8 @@ use crate::entities::common::{ edit_angle_prop as edit_angle, edit_prop as edit, num_prop as num_row, ro_prop as ro, square_grip, triangle_grip, }; use crate::entities::text_support::{ - layout_mtext, resolve_text_style, GlyphBox, MTextColumns, MTextRenderOpts, MTextVAnchor, + clamp_mtext_column_count, layout_mtext, resolve_text_style, GlyphBox, MTextColumns, + MTextRenderOpts, MTextVAnchor, }; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject}; @@ -24,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 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 { 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 { // 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 { .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 { .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); } _ => {} } diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index d2568013..4084a780 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -182,9 +182,24 @@ fn to_render(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option= 0.0 { 0.0 } else { 1.0 }, - v_anchor: mleader_v_anchor(ctx.text_left_attachment), + attach_h_anchor: match ctx.text_attachment_point { + acadrust::entities::multileader::TextAttachmentPointType::Left => 0.0, + acadrust::entities::multileader::TextAttachmentPointType::Center => 0.5, + acadrust::entities::multileader::TextAttachmentPointType::Right => 1.0, + }, + v_anchor: mleader_v_anchor( + if text_sign >= 0.0 { + ctx.text_left_attachment + } else { + ctx.text_right_attachment + }, + ), 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, })) @@ -200,11 +215,19 @@ fn to_render(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option Option = line.points.iter().map(|p| p3(p)).collect(); - let last_f = *ctrl.last().unwrap_or(&landing_pt); - let dist = ((last_f[0] - landing_pt[0]).powi(2) - + (last_f[1] - landing_pt[1]).powi(2)) + let mut ctrl: Vec<[f64; 3]> = + line.points.iter().map(|p| p3(p)).collect(); + + let last_f = *ctrl.last().unwrap_or(&elbow_pt); + + let dist = ((last_f[0] - elbow_pt[0]).powi(2) + + (last_f[1] - elbow_pt[1]).powi(2)) .sqrt(); + if dist > 1e-9 { - ctrl.push(landing_pt); + ctrl.push(elbow_pt); } for &c in &ctrl { key_verts.push(c); @@ -295,8 +322,8 @@ fn to_render(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option 0.0 { points.push(nan); - points.push(landing_pt); - points.push([text_loc.x, text_loc.y, text_loc.z]); + points.push(elbow_pt); + points.push(text_edge_pt); } } @@ -339,9 +366,7 @@ fn to_render(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option ([f64; 2], [f64; 3]) { oblique_angle: 0.0, is_backward: false, is_upside_down: false, + is_vertical: false, }; let layout = layout_mtext(&MTextRenderOpts { columns: Default::default(), @@ -404,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, }); @@ -428,27 +459,131 @@ fn text_box_geom(ml: &MultiLeader) -> ([f64; 2], [f64; 3]) { ], ) } +fn mleader_landing_geom( + ml: &MultiLeader, +) -> Option<(DVec3, DVec3, f64, f64, f64)> { + if ml.content_type != LeaderContentType::MText + || !ml.enable_landing + || !ml.enable_dogleg + { + return None; + } + + let text = DVec3::new( + ml.context.text_location.x, + ml.context.text_location.y, + ml.context.text_location.z, + ); + + let td = ml.context.text_direction; + + let axis = { + let d = DVec3::new(td.x, td.y, td.z); + + if d.length_squared() > 1.0e-18 { + d.normalize() + } else { + let a = ml.context.text_rotation; + + DVec3::new(a.cos(), a.sin(), 0.0) + } + }; + + let leader_ref = ml + .context + .leader_roots + .first() + .and_then(|root| root.lines.first()) + .and_then(|line| line.points.last()) + .map(|p| DVec3::new(p.x, p.y, p.z)) + .or_else(|| { + ml.context.leader_roots.first().map(|root| { + DVec3::new( + root.connection_point.x, + root.connection_point.y, + root.connection_point.z, + ) + }) + }) + .unwrap_or(text); + + let sign = if (text - leader_ref).dot(axis) >= 0.0 { + 1.0 + } else { + -1.0 + }; + + let dogleg = ml + .context + .leader_roots + .first() + .map(|root| root.landing_distance.max(0.0)) + .unwrap_or_else(|| ml.dogleg_length.max(0.0)); + + let gap = ml.context.landing_gap.max(0.0); + + if dogleg <= 1.0e-9 { + return None; + } + + let elbow = text - axis * (sign * (dogleg + gap)); + + Some((elbow, axis, sign, dogleg, gap)) +} + +fn set_mleader_text_location(ml: &mut MultiLeader, location: DVec3) { + let location = acadrust::types::Vector3::new(location.x, location.y, location.z); + ml.context.text_location = location; + ml.context.content_base_point = location; +} fn grips(ml: &MultiLeader) -> Vec { - let mut result: Vec = Vec::new(); + let mut result = Vec::new(); let mut id = 0usize; for root in &ml.context.leader_roots { for line in &root.lines { for p in &line.points { - result.push(square_grip(id, glam::DVec3::new(p.x, p.y, p.z))); + result.push(square_grip( + id, + DVec3::new(p.x, p.y, p.z), + )); + id += 1; } } } if ml.content_type == LeaderContentType::MText { + if let Some((elbow, _, _, _, _)) = mleader_landing_geom(ml) + { + result.push(square_grip( + id, + elbow, + )); + + id += 1; + } + let tl = &ml.context.text_location; - // Text-location grip, then the wrap-width grip at the box's far edge. - result.push(center_grip(id, glam::DVec3::new(tl.x, tl.y, tl.z))); + + result.push(center_grip( + id, + DVec3::new(tl.x, tl.y, tl.z), + )); + id += 1; + let (_, far) = text_box_geom(ml); - result.push(triangle_grip(id, glam::DVec3::new(far[0], far[1], far[2]))); + + result.push(triangle_grip( + id, + DVec3::new( + far[0], + far[1], + far[2], + ), + )); } result @@ -458,16 +593,29 @@ fn grips(ml: &MultiLeader) -> Vec { /// text grip's "Move with Leader" action so the leader follows the text. pub(crate) const MOVE_ALL_GRIP: usize = usize::MAX; -fn apply_grip(ml: &mut MultiLeader, grip_id: usize, apply: GripApply) { +fn apply_grip( + ml: &mut MultiLeader, + grip_id: usize, + apply: GripApply, +) { if grip_id == MOVE_ALL_GRIP { let (dx, dy, dz) = match apply { - GripApply::Translate(d) => (d.x as f64, d.y as f64, d.z as f64), + GripApply::Translate(d) => ( + d.x as f64, + d.y as f64, + d.z as f64, + ), + GripApply::Absolute(a) => ( - a.x as f64 - ml.context.text_location.x, - a.y as f64 - ml.context.text_location.y, - a.z as f64 - ml.context.text_location.z, + a.x as f64 + - ml.context.text_location.x, + a.y as f64 + - ml.context.text_location.y, + a.z as f64 + - ml.context.text_location.z, ), }; + for root in &mut ml.context.leader_roots { for line in &mut root.lines { for p in &mut line.points { @@ -476,13 +624,21 @@ fn apply_grip(ml: &mut MultiLeader, grip_id: usize, apply: GripApply) { p.z += dz; } } + root.connection_point.x += dx; root.connection_point.y += dy; root.connection_point.z += dz; } - ml.context.text_location.x += dx; - ml.context.text_location.y += dy; - ml.context.text_location.z += dz; + + set_mleader_text_location( + ml, + DVec3::new( + ml.context.text_location.x + dx, + ml.context.text_location.y + dy, + ml.context.text_location.z + dz, + ), + ); + return; } @@ -498,55 +654,130 @@ fn apply_grip(ml: &mut MultiLeader, grip_id: usize, apply: GripApply) { p.y = a.y as f64; p.z = a.z as f64; } + GripApply::Translate(d) => { p.x += d.x as f64; p.y += d.y as f64; p.z += d.z as f64; } } + return; } + idx += 1; } } } - // Text-location grip (idx == n_vertices), then the wrap-width grip. - if ml.content_type == LeaderContentType::MText { - if idx == grip_id { - let tl = &mut ml.context.text_location; - match apply { - GripApply::Absolute(a) => { - tl.x = a.x as f64; - tl.y = a.y as f64; - tl.z = a.z as f64; - } + if ml.content_type != LeaderContentType::MText { + return; + } + + let landing = mleader_landing_geom(ml); + + if let Some((old_elbow, axis, old_sign, dogleg, gap)) = landing { + if grip_id == idx { + let target = match apply { + GripApply::Absolute(a) => DVec3::new(a.x as f64, a.y as f64, a.z as f64), GripApply::Translate(d) => { - tl.x += d.x as f64; - tl.y += d.y as f64; - tl.z += d.z as f64; + old_elbow + DVec3::new(d.x as f64, d.y as f64, d.z as f64) } + }; + let arrow = ml + .context + .leader_roots + .first() + .and_then(|root| root.lines.first()) + .and_then(|line| line.points.first()) + .map(|p| DVec3::new(p.x, p.y, p.z)) + .unwrap_or(old_elbow); + let side_distance = (target - arrow).dot(axis); + let flip_tol = (dogleg * 0.10).max(1.0e-6); + let new_sign = if side_distance > flip_tol { + 1.0 + } else if side_distance < -flip_tol { + -1.0 + } else { + old_sign + }; + ml.context.text_attachment_point = if new_sign >= 0.0 { + acadrust::entities::multileader::TextAttachmentPointType::Left + } else { + acadrust::entities::multileader::TextAttachmentPointType::Right + }; + set_mleader_text_location(ml, target + axis * (new_sign * (dogleg + gap))); + + if let Some(root) = ml.context.leader_roots.first_mut() { + root.connection_point.x = target.x; + root.connection_point.y = target.y; + root.connection_point.z = target.z; + root.direction.x = axis.x * new_sign; + root.direction.y = axis.y * new_sign; + root.direction.z = axis.z * new_sign; } return; } + idx += 1; - if idx == grip_id { - // Dragging the box edge sets the MText wrap limit, projected on - // the box's flow axis (rotated baseline, or down the column for - // vertical flow). - let (dir, far) = text_box_geom(ml); - let (nx, ny) = match apply { - GripApply::Absolute(a) => (a.x as f64, a.y as f64), - GripApply::Translate(d) => (far[0] + d.x as f64, far[1] + d.y as f64), - }; - let tl = &ml.context.text_location; - // `dir` carries the attachment-side factor (may be ±half), so - // normalise by its squared length: width = proj(flow)/k. - let d2 = (dir[0] * dir[0] + dir[1] * dir[1]).max(1e-12); - let proj = ((nx - tl.x) * dir[0] + (ny - tl.y) * dir[1]) / d2; - let min_w = ml.text_height.max(1.0) * 0.5; - ml.context.text_width = proj.max(min_w); + } + + if grip_id == idx { + let old_end = DVec3::new( + ml.context.text_location.x, + ml.context.text_location.y, + ml.context.text_location.z, + ); + let target = match apply { + GripApply::Absolute(a) => DVec3::new(a.x as f64, a.y as f64, a.z as f64), + GripApply::Translate(d) => { + old_end + DVec3::new(d.x as f64, d.y as f64, d.z as f64) + } + }; + + if let Some((elbow, axis, sign, _, gap)) = landing { + let requested_total = (target - elbow).dot(axis) * sign; + let new_length = (requested_total - gap).max(1.0e-6); + set_mleader_text_location( + ml, + elbow + axis * (sign * (new_length + gap)), + ); + ml.dogleg_length = new_length; + for root in &mut ml.context.leader_roots { + root.landing_distance = new_length; + } + } else { + set_mleader_text_location(ml, target); } + return; + } + + idx += 1; + + if grip_id == idx { + let (dir, far) = text_box_geom(ml); + + let (nx, ny) = match apply { + GripApply::Absolute(a) => ( + a.x as f64, + a.y as f64, + ), + + GripApply::Translate(d) => ( + far[0] + d.x as f64, + far[1] + d.y as f64, + ), + }; + + let tl = &ml.context.text_location; + + let d2 = (dir[0] * dir[0] + dir[1] * dir[1]).max(1.0e-12); + + let proj = ((nx - tl.x) * dir[0] + (ny - tl.y) * dir[1]) / d2; + + let min_w = ml.text_height.max(1.0) * 0.5; + + ml.context.text_width = proj.max(min_w); } } @@ -842,16 +1073,19 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { "text_x" => { if let Some(v) = f64(value) { ml.context.text_location.x = v; + ml.context.content_base_point.x = v; } } "text_y" => { if let Some(v) = f64(value) { ml.context.text_location.y = v; + ml.context.content_base_point.y = v; } } "text_z" => { if let Some(v) = f64(value) { ml.context.text_location.z = v; + ml.context.content_base_point.z = v; } } "text_frame" => { @@ -1090,6 +1324,7 @@ fn apply_transform(ml: &mut MultiLeader, t: &EntityTransform) { p1, p2, ); + entity.context.content_base_point = entity.context.text_location; reflect_xy_direction( &mut entity.context.text_direction.x, &mut entity.context.text_direction.y, @@ -1133,53 +1368,83 @@ impl crate::entities::traits::Grippable for MultiLeader { fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { apply_grip(self, grip_id, apply); } - fn grip_menu(&self, grip_id: usize) -> Vec { - use crate::scene::model::object::{GripMenuAction, GripMenuItem}; + fn grip_menu( + &self, + grip_id: usize, + ) -> Vec { + use crate::scene::model::object::{ + GripMenuAction, + GripMenuItem, + }; + let n_vertices: usize = self .context .leader_roots .iter() - .flat_map(|r| r.lines.iter()) - .map(|l| l.points.len()) + .flat_map(|root| root.lines.iter()) + .map(|line| line.points.len()) .sum(); - if self.content_type == LeaderContentType::MText && grip_id >= n_vertices { - if grip_id == n_vertices { - // Text-location grip. - vec![ + + if self.content_type + == LeaderContentType::MText + { + let has_elbow = + mleader_landing_geom(self).is_some(); + + let elbow_id = + n_vertices; + + let landing_id = + n_vertices + + usize::from(has_elbow); + + let width_id = + landing_id + 1; + + if has_elbow + && grip_id == elbow_id + { + return vec![ GripMenuItem { label: "Stretch", - action: GripMenuAction::Stretch, + action: + GripMenuAction::Stretch, }, - GripMenuItem { - label: "Move with Leader", - action: GripMenuAction::MoveWithLeader, - }, - GripMenuItem { - label: "Move Independent", - action: GripMenuAction::MoveIndependent, - }, - ] - } else { - // Wrap-width grip: drag only. - Vec::new() + ]; + } + + if grip_id == landing_id { + return vec![ + GripMenuItem { + label: "Stretch", + action: + GripMenuAction::Stretch, + }, + ]; + } + + if grip_id == width_id { + return Vec::new(); } - } else { - // Leader-line vertex. - vec![ - GripMenuItem { - label: "Stretch", - action: GripMenuAction::Stretch, - }, - GripMenuItem { - label: "Add Leader", - action: GripMenuAction::AddLeader, - }, - GripMenuItem { - label: "Remove Leader", - action: GripMenuAction::RemoveLeader, - }, - ] } + + vec![ + GripMenuItem { + label: "Stretch", + action: + GripMenuAction::Stretch, + }, + GripMenuItem { + label: "Add Leader", + action: + GripMenuAction::AddLeader, + }, + GripMenuItem { + label: "Remove Leader", + action: + GripMenuAction::RemoveLeader, + }, + ] } fn apply_grip_menu(&mut self, grip_id: usize, action: crate::scene::model::object::GripMenuAction) { use crate::scene::model::object::GripMenuAction as A; @@ -1323,16 +1588,40 @@ impl MultiLeaderTess for MultiLeader { // ── Scaling ────────────────────────────────────────────────────────────── // Used only when a context omits an already-resolved content size. - let fallback_content_scale = (ml.scale_factor as f32) - * if crate::scene::annotative::mleader_is_annotative(document, ml) { - anno_scale + let annotative = + crate::scene::annotative::mleader_is_annotative(document, ml); + + // Context sizes are resolved at their stored annotation scale. + let base_scale = if ml.scale_factor.abs() > 1.0e-12 { + ml.scale_factor as f32 + } else { + 1.0 + }; + + let stored_context_scale = ml.context.scale_factor as f32; + + let context_scale_correction = + if annotative && stored_context_scale.abs() > 1.0e-12 { + let correction = + (base_scale * anno_scale) / stored_context_scale; + + if correction.is_finite() && correction > 0.0 { + correction + } else { + 1.0 + } } else { 1.0 }; + let fallback_content_scale = + base_scale * if annotative { anno_scale } else { 1.0 }; + // The active context stores the resolved world-space arrow size. // Reapplying the entity scale here makes context-sized arrows grow twice. - let arrow_size = ml.context.arrowhead_size as f32; + let arrow_size = + ml.context.arrowhead_size as f32 + * context_scale_correction; let draw_arrow = arrow_size > 0.0; let invisible = ml.path_type == MultiLeaderPathType::Invisible; // arrowhead_handle resolves through the block records to a named arrow @@ -1357,7 +1646,25 @@ impl MultiLeaderTess for MultiLeader { // Which side the text grip sits on, recomputed every frame so the text // alignment and the landing mirror live when the arrow or text moves. - let text_loc_w = ml.context.text_location; + let mut text_loc_w = ml.context.text_location; + + // Scale the annotation-side offset around the fixed elbow. + if annotative + && (context_scale_correction - 1.0).abs() > 1.0e-6 + { + if let Some(root) = ml.context.leader_roots.first() { + let anchor = root.connection_point; + let k = context_scale_correction as f64; + + text_loc_w = acadrust::types::Vector3::new( + anchor.x + + (text_loc_w.x - anchor.x) * k, + anchor.y + + (text_loc_w.y - anchor.y) * k, + text_loc_w.z, + ); + } + } let leader_ref_w = ml .context .leader_roots @@ -1471,7 +1778,9 @@ impl MultiLeaderTess for MultiLeader { // stray line up the side of the text. // Landing distance belongs to the selected leader-root context // and is already resolved in world units. - let d = root.landing_distance; + let d = + root.landing_distance + * context_scale_correction as f64; // The dogleg runs along the leader root's stored direction — // for a rotated leader that is the angled baseline, not world // X. Roots without a usable direction keep the legacy @@ -1505,6 +1814,7 @@ impl MultiLeaderTess for MultiLeader { wires.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -1633,11 +1943,13 @@ impl MultiLeaderTess for MultiLeader { // scale_factor + annotation scale applied. let height = if ctx.text_height > 0.0 { ctx.text_height as f32 + * context_scale_correction } else { - ml.text_height as f32 * fallback_content_scale + ml.text_height as f32 + * fallback_content_scale }; - let ins = &ctx.text_location; + let ins = &text_loc_w; // Subtract world_offset in f64 before casting to f32: drawings often // sit at large absolute coordinates and casting first then subtracting // throws away the precision needed for the rotated sub-glyph offsets. @@ -1730,12 +2042,19 @@ impl MultiLeaderTess for MultiLeader { value: &ctx.text_string, insertion: [local_ins_x as f64, local_ins_y as f64, z as f64], height, - rect_w: ctx.text_width as f32, + rect_w: + ctx.text_width as f32 + * context_scale_correction, rotation: rot, style: &style, 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, @@ -1845,6 +2164,7 @@ impl MultiLeaderTess for MultiLeader { wires.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -1918,6 +2238,7 @@ impl MultiLeaderTess for MultiLeader { wires.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -2006,6 +2327,7 @@ impl MultiLeaderTess for MultiLeader { wires.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -2049,6 +2371,7 @@ impl MultiLeaderTess for MultiLeader { wires.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/entities/polyline.rs b/src/entities/polyline.rs index 6e63b558..54d98ab7 100644 --- a/src/entities/polyline.rs +++ b/src/entities/polyline.rs @@ -195,7 +195,7 @@ pub fn drawn_vertices2d( (kept.len() >= 2).then_some(kept) } -fn tessellate_polyline2d(pl: &Polyline2D) -> RenderEntity { +fn tessellate_polyline2d(pl: &Polyline2D, fill_mode: bool) -> RenderEntity { let filtered = drawn_vertices2d(pl); let verts: &[acadrust::entities::Vertex2D] = filtered.as_deref().unwrap_or(&pl.vertices); if verts.is_empty() { @@ -224,6 +224,47 @@ fn tessellate_polyline2d(pl: &Polyline2D) -> RenderEntity { [wx, wy, wz] }; + if !fill_mode { + let continuous = pl.flags.bits() + & acadrust::entities::PolylineFlags::LINETYPE_CONTINUOUS.bits() + != 0; + let mut boundary = crate::entities::common::wide_band_outline( + &band_verts_2d(pl), + pl.is_closed(), + !continuous, + &to_wcs, + ); + if !boundary.points.is_empty() { + if pl.thickness.abs() > 1e-10 { + boundary = crate::entities::common::extrude_wide_band_outline( + boundary, + [ + pl.thickness * normal.0, + pl.thickness * normal.1, + pl.thickness * normal.2, + ], + ); + } + let (tangent_geoms, key_vertices) = + centerline_metadata_2d(verts, pl.is_closed(), &to_wcs); + return RenderEntity { + pick_tris: Vec::new(), + object: RenderObject::BoundaryLines { + points: boundary.points, + stations: boundary.stations, + point_segments: boundary.point_segments, + station_pieces: boundary.station_pieces, + source_length: boundary.source_length, + plinegen: continuous, + }, + snap_pts: vec![], + tangent_geoms, + key_vertices, + fill_tris: vec![], + }; + } + } + if pl.thickness.abs() > 1e-10 { let (nx, ny, nz) = normal; let t = pl.thickness; @@ -348,13 +389,14 @@ fn tessellate_polyline2d(pl: &Polyline2D) -> RenderEntity { key_verts.push([p1[0], p1[1], p1[2]]); } + let band_verts = band_verts_2d(pl); let (fill_origin, fills) = wide_fills(pl); // A wide Polyline2D whose per-vertex widths VARY renders a smooth taper; a // uniform-width one keeps the constant-band Contour. - let object = match tapered_band_verts_2d(pl) { + let object = match tapered_band_verts_2d(&band_verts) { Some(band_verts) => { let (pts, widths) = crate::entities::common::tapered_band_points( - &band_verts, + band_verts, pl.is_closed(), &to_wcs, ); @@ -376,16 +418,15 @@ fn tessellate_polyline2d(pl: &Polyline2D) -> RenderEntity { } } -/// Per-vertex `(location, bulge, start_width, end_width)` band description for a -/// wide Polyline2D whose width VARIES — `None` when the width is uniform. -fn tapered_band_verts_2d( +/// Effective segment widths for a Polyline2D band. +fn band_verts_2d( pl: &acadrust::entities::Polyline2D, -) -> Option> { +) -> Vec<([f64; 2], f64, f64, f64)> { let default_start = pl.start_width; let default_end = pl.end_width; let filtered = drawn_vertices2d(pl); let verts: &[acadrust::entities::Vertex2D] = filtered.as_deref().unwrap_or(&pl.vertices); - let band: Vec<([f64; 2], f64, f64, f64)> = verts + verts .iter() .map(|v| { let sw = if v.start_width > 1e-9 { @@ -400,7 +441,12 @@ fn tapered_band_verts_2d( }; ([v.location.x, v.location.y], v.bulge, sw, ew) }) - .collect(); + .collect() +} + +fn tapered_band_verts_2d( + band: &[([f64; 2], f64, f64, f64)], +) -> Option<&[([f64; 2], f64, f64, f64)]> { let w0 = band.first().map_or(0.0, |v| v.2); let varies = band .iter() @@ -412,9 +458,51 @@ fn tapered_band_verts_2d( } } +fn centerline_metadata_2d( + verts: &[acadrust::entities::Vertex2D], + closed: bool, + to_wcs: &dyn Fn(f64, f64) -> (f64, f64, f64), +) -> (Vec, Vec<[f64; 3]>) { + let count = verts.len(); + let segment_count = if closed { + count + } else { + count.saturating_sub(1) + }; + let mut tangents = Vec::with_capacity(segment_count); + let mut key_vertices = Vec::with_capacity(segment_count + 1); + for index in 0..segment_count { + let start = &verts[index]; + let end = &verts[(index + 1) % count]; + let p0 = to_wcs(start.location.x, start.location.y); + let p1 = to_wcs(end.location.x, end.location.y); + if start.bulge.abs() < 1e-9 { + tangents.push(TangentGeom::Line { + p1: [p0.0 as f32, p0.1 as f32, p0.2 as f32], + p2: [p1.0 as f32, p1.1 as f32, p1.2 as f32], + }); + } else if let Some(arc) = crate::entities::common::BulgeArc::from_bulge( + [start.location.x, start.location.y], + [end.location.x, end.location.y], + start.bulge, + ) { + let center = to_wcs(arc.center[0], arc.center[1]); + tangents.push(TangentGeom::Circle { + center: [center.0 as f32, center.1 as f32, center.2 as f32], + radius: arc.radius as f32, + }); + } + if index == 0 { + key_vertices.push([p0.0, p0.1, p0.2]); + } + key_vertices.push([p1.0, p1.1, p1.2]); + } + (tangents, key_vertices) +} + impl RenderConvertible for Polyline2D { - fn to_render(&self, _document: &acadrust::CadDocument) -> Option { - Some(tessellate_polyline2d(self)) + fn to_render(&self, document: &acadrust::CadDocument) -> Option { + Some(tessellate_polyline2d(self, document.header.fill_mode)) } } diff --git a/src/entities/raster_image.rs b/src/entities/raster_image.rs index 604d48d3..51937cf5 100644 --- a/src/entities/raster_image.rs +++ b/src/entities/raster_image.rs @@ -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, diff --git a/src/entities/table.rs b/src/entities/table.rs index 67acd76b..0a75045d 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -13,6 +13,27 @@ use crate::scene::view::transform; use crate::scene::model::wire_model::SnapHint; use crate::t; +thread_local! { + static PROPERTY_CELL: std::cell::Cell = const { std::cell::Cell::new(0) }; + static PROPERTY_CELL_ACTIVE: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +pub fn set_prop_current_cell(index: usize) { + PROPERTY_CELL.with(|cell| cell.set(index)); +} + +pub fn set_prop_current_cell_active(active: bool) { + PROPERTY_CELL_ACTIVE.with(|cell| cell.set(active)); +} + +fn prop_current_cell() -> usize { + PROPERTY_CELL.with(std::cell::Cell::get) +} + +fn prop_current_cell_active() -> bool { + PROPERTY_CELL_ACTIVE.with(std::cell::Cell::get) +} + fn v3(v: &acadrust::types::Vector3) -> Vec3 { Vec3::new(v.x as f32, v.y as f32, v.z as f32) } @@ -66,7 +87,7 @@ fn merged_owner_and_span( Some((row, column, row, column)) } -fn style_for_property<'a>( +pub(crate) fn style_for_property<'a>( table: &'a Table, row: &'a acadrust::entities::table::TableRow, column: usize, @@ -77,7 +98,12 @@ fn style_for_property<'a>( .columns .get(column) .and_then(|column| column.style.as_ref()); - for style in [cell.style.as_ref(), row.style.as_ref(), column_style] + for style in [ + cell.style.as_ref(), + row.style.as_ref(), + column_style, + table.base_style.as_ref(), + ] .into_iter() .flatten() { @@ -85,12 +111,99 @@ fn style_for_property<'a>( return Some(style); } } - table.base_style.as_ref().or_else(|| { - [cell.style.as_ref(), row.style.as_ref(), column_style] - .into_iter() - .flatten() - .next() - }) + None +} + +fn style_for_border<'a>( + table: &'a Table, + row: &'a acadrust::entities::table::TableRow, + column: usize, + cell: &'a acadrust::entities::table::TableCell, + edge: acadrust::entities::table::CellEdgeFlags, +) -> Option<&'a acadrust::entities::table::CellStyle> { + let column_style = table + .columns + .get(column) + .and_then(|column| column.style.as_ref()); + [ + cell.style.as_ref(), + row.style.as_ref(), + column_style, + table.base_style.as_ref(), + ] + .into_iter() + .flatten() + .find(|style| style.applied_border_edges.contains(edge)) +} + +pub(crate) fn resolved_title_suppressed( + table: &Table, + table_style: Option<&acadrust::objects::TableStyle>, +) -> bool { + table + .legacy_style_override + .as_ref() + .and_then(|style| style.title_suppressed) + .or_else(|| table_style.map(|style| style.title_suppressed)) + .unwrap_or(false) +} + +pub(crate) fn resolved_header_suppressed( + table: &Table, + table_style: Option<&acadrust::objects::TableStyle>, +) -> bool { + table + .legacy_style_override + .as_ref() + .and_then(|style| style.header_suppressed) + .or_else(|| table_style.map(|style| style.header_suppressed)) + .unwrap_or(false) +} + +pub(crate) fn resolved_flow_up( + table: &Table, + table_style: Option<&acadrust::objects::TableStyle>, +) -> bool { + use acadrust::entities::table::CellStylePropertyFlags; + + table + .legacy_style_override + .as_ref() + .and_then(|style| style.flow_direction) + .map(|flow| flow != 0) + .or_else(|| { + table.base_style.as_ref().and_then(|style| { + style + .property_flags + .contains(CellStylePropertyFlags::FLOW_DIRECTION_BOTTOM_TO_TOP) + .then_some(true) + }) + }) + .unwrap_or_else(|| { + matches!( + table_style.map(|style| style.flow_direction), + Some(acadrust::objects::TableFlowDirection::Up) + ) + }) +} + +pub(crate) fn resolved_table_margins( + table: &Table, + table_style: Option<&acadrust::objects::TableStyle>, +) -> (f64, f64) { + let horizontal = table + .legacy_style_override + .as_ref() + .and_then(|style| style.horizontal_cell_margin) + .or_else(|| table_style.map(|style| style.horizontal_margin)) + .unwrap_or(0.0); + let vertical = table + .legacy_style_override + .as_ref() + .and_then(|style| style.vertical_cell_margin) + .or_else(|| table_style.map(|style| style.vertical_margin)) + .unwrap_or(0.0); + (horizontal, vertical) } fn table_offsets(table: &Table, scale: f32) -> (Vec, Vec) { @@ -108,40 +221,68 @@ fn table_offsets(table: &Table, scale: f32) -> (Vec, Vec) { (columns, rows) } -fn break_frame_for_row( +#[derive(Clone, Copy)] +struct TableBreakSegment { + start_row: usize, + end_row: usize, + origin: Vec3, +} + +fn table_break_segments( table: &Table, - row: usize, h: Vec3, down: Vec3, row_offsets: &[f32], scale: f32, -) -> (Vec3, f32) { +) -> Vec { use acadrust::entities::table::BreakOptionFlags; let insertion = v3(&table.insertion_point); - let offset_to_world = - |offset: &acadrust::types::Vector3| insertion + v3(offset); + if table.rows.is_empty() { + return Vec::new(); + } if !table.break_options.contains(BreakOptionFlags::ENABLE_BREAKS) { - return (insertion, row_offsets.get(row).copied().unwrap_or(0.0)); + return vec![TableBreakSegment { + start_row: 0, + end_row: table.rows.len() - 1, + origin: insertion, + }]; } - if let Some(range) = table + let offset_to_world = |offset: &acadrust::types::Vector3| insertion + v3(offset); + let mut cached: Vec<_> = table .break_ranges .iter() - .find(|range| row as i32 >= range.start_row && row as i32 <= range.end_row) - { - let start = range.start_row.max(0) as usize; - let position = offset_to_world(&range.position); - let origin = if position.is_finite() { - position - } else { - insertion - }; - let top = row_offsets.get(row).copied().unwrap_or(0.0) - - row_offsets.get(start).copied().unwrap_or(0.0); - return (origin, top); + .filter_map(|range| { + let start = range.start_row.max(0) as usize; + let end = range.end_row.max(0) as usize; + (start <= end && end < table.rows.len()).then(|| TableBreakSegment { + start_row: start, + end_row: end, + origin: { + let position = offset_to_world(&range.position); + if position.is_finite() { + position + } else { + insertion + } + }, + }) + }) + .collect(); + cached.sort_by_key(|segment| segment.start_row); + let cached_complete = cached.first().is_some_and(|segment| segment.start_row == 0) + && cached + .last() + .is_some_and(|segment| segment.end_row + 1 == table.rows.len()) + && cached + .windows(2) + .all(|pair| pair[0].end_row + 1 == pair[1].start_row); + if cached_complete { + return cached; } + let mut segments = Vec::new(); let mut start_row = 0usize; let mut segment = 0usize; while start_row < table.rows.len() { @@ -161,48 +302,154 @@ fn break_frame_for_row( { end_row += 1; } - if row <= end_row { - let manual_positions = table - .break_options - .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS); - let manual_origin = manual_positions - .then(|| table.break_data.get(segment)) - .flatten() - .map(|data| offset_to_world(&data.position)) - .filter(|position| position.is_finite()); - let origin = manual_origin.unwrap_or_else(|| { - let spacing = table.break_spacing as f32 * scale; - let horizontal_step = - table.total_width() as f32 * scale + spacing; - let vertical_step = if max_height.is_finite() { - max_height + spacing - } else { - table.total_height() as f32 * scale + spacing - }; - match table.break_flow_direction { - acadrust::entities::table::BreakFlowDirection::Left => { - insertion - h * segment as f32 * horizontal_step - } - acadrust::entities::table::BreakFlowDirection::Vertical => { - insertion + down * segment as f32 * vertical_step - } - acadrust::entities::table::BreakFlowDirection::Right => { - insertion + h * segment as f32 * horizontal_step - } + let manual_positions = table + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS); + let manual_origin = manual_positions + .then(|| table.break_data.get(segment)) + .flatten() + .map(|data| offset_to_world(&data.position)) + .filter(|position| position.is_finite()); + let origin = manual_origin.unwrap_or_else(|| { + let spacing = table.break_spacing as f32 * scale; + let horizontal_step = table.total_width() as f32 * scale + spacing; + let vertical_step = if max_height.is_finite() { + max_height + spacing + } else { + table.total_height() as f32 * scale + spacing + }; + match table.break_flow_direction { + acadrust::entities::table::BreakFlowDirection::Left => { + insertion - h * segment as f32 * horizontal_step } - }); - return ( - origin, - row_offsets.get(row).copied().unwrap_or(0.0) - start_offset, - ); - } + acadrust::entities::table::BreakFlowDirection::Vertical => { + insertion + down * segment as f32 * vertical_step + } + acadrust::entities::table::BreakFlowDirection::Right => { + insertion + h * segment as f32 * horizontal_step + } + } + }); + segments.push(TableBreakSegment { + start_row, + end_row, + origin, + }); start_row = end_row.saturating_add(1); segment = segment.saturating_add(1); } + segments +} + +fn break_frame_for_row( + table: &Table, + row: usize, + h: Vec3, + down: Vec3, + row_offsets: &[f32], + scale: f32, +) -> (Vec3, f32) { + let insertion = v3(&table.insertion_point); + if let Some(segment) = table_break_segments(table, h, down, row_offsets, scale) + .into_iter() + .find(|segment| row >= segment.start_row && row <= segment.end_row) + { + return ( + segment.origin, + row_offsets.get(row).copied().unwrap_or(0.0) + - row_offsets + .get(segment.start_row) + .copied() + .unwrap_or(0.0), + ); + } + (insertion, row_offsets.get(row).copied().unwrap_or(0.0)) } +fn break_frames_for_row( + table: &Table, + row: usize, + h: Vec3, + down: Vec3, + row_offsets: &[f32], + scale: f32, + top_label_rows: usize, + bottom_label_rows: usize, +) -> Vec<(Vec3, f32)> { + use acadrust::entities::table::BreakOptionFlags; + + let segments = table_break_segments(table, h, down, row_offsets, scale); + let Some((segment_index, segment)) = segments + .iter() + .enumerate() + .find(|(_, segment)| row >= segment.start_row && row <= segment.end_row) + else { + return vec![break_frame_for_row(table, row, h, down, row_offsets, scale)]; + }; + let repeat_top = table + .break_options + .contains(BreakOptionFlags::REPEAT_TOP_LABELS) + && top_label_rows > 0; + let repeat_bottom = table + .break_options + .contains(BreakOptionFlags::REPEAT_BOTTOM_LABELS) + && bottom_label_rows > 0; + let top_label_rows = top_label_rows.min(table.rows.len()); + let bottom_label_rows = bottom_label_rows.min(table.rows.len()); + let bottom_start = table.rows.len().saturating_sub(bottom_label_rows); + let top_height = row_offsets + .get(top_label_rows) + .copied() + .unwrap_or(0.0); + let mut primary_top = row_offsets.get(row).copied().unwrap_or(0.0) + - row_offsets + .get(segment.start_row) + .copied() + .unwrap_or(0.0); + if repeat_top && segment_index > 0 { + primary_top += top_height; + } + let mut frames = vec![(segment.origin, primary_top)]; + + if repeat_top && row < top_label_rows { + for repeated_segment in segments.iter().skip(1) { + frames.push(( + repeated_segment.origin, + row_offsets.get(row).copied().unwrap_or(0.0), + )); + } + } + if repeat_bottom && row >= bottom_start { + let label_offset = row_offsets.get(row).copied().unwrap_or(0.0) + - row_offsets.get(bottom_start).copied().unwrap_or(0.0); + for (index, repeated_segment) in segments + .iter() + .enumerate() + .take(segments.len().saturating_sub(1)) + { + let content_height = row_offsets + .get(repeated_segment.end_row + 1) + .copied() + .unwrap_or(0.0) + - row_offsets + .get(repeated_segment.start_row) + .copied() + .unwrap_or(0.0); + let repeated_top = content_height + + if repeat_top && index > 0 { + top_height + } else { + 0.0 + } + + label_offset; + frames.push((repeated_segment.origin, repeated_top)); + } + } + frames +} + fn format_cell_value(value: &acadrust::entities::table::CellValue) -> String { let display = value.display(); if !display.is_empty() { @@ -227,6 +474,71 @@ fn format_cell_value(value: &acadrust::entities::table::CellValue) -> String { } } +fn table_cell_reference(reference: &str) -> Option<(usize, usize)> { + let reference = reference.trim(); + let split = reference + .char_indices() + .find(|(_, ch)| ch.is_ascii_digit())? + .0; + let (letters, digits) = reference.split_at(split); + if letters.is_empty() || digits.is_empty() { + return None; + } + let mut column = 0usize; + for ch in letters.chars() { + if !ch.is_ascii_alphabetic() { + return None; + } + column = column + .checked_mul(26)? + .checked_add(ch.to_ascii_uppercase() as usize - 'A' as usize + 1)?; + } + let row = digits.parse::().ok()?.checked_sub(1)?; + Some((row, column.checked_sub(1)?)) +} + +fn evaluate_table_formula(table: &Table, expression: &str) -> Option { + let body = expression.trim().strip_prefix('=')?.trim(); + if let Some((row, column)) = table_cell_reference(body) { + return table.cell(row, column).map(|cell| cell.text_value().to_string()); + } + let open = body.find('(')?; + let close = body.rfind(')')?; + let function = body[..open].trim().to_ascii_uppercase(); + let range = &body[open + 1..close]; + let (first, last) = range.split_once(':').unwrap_or((range, range)); + let (r1, c1) = table_cell_reference(first)?; + let (r2, c2) = table_cell_reference(last)?; + let mut values = Vec::new(); + for row in r1.min(r2)..=r1.max(r2) { + for column in c1.min(c2)..=c1.max(c2) { + if let Some(value) = table + .cell(row, column) + .and_then(|cell| cell.text_value().trim().parse::().ok()) + { + values.push(value); + } + } + } + match function.as_str() { + "SUM" => Some(values.iter().sum::().to_string()), + "AVERAGE" | "AVG" if !values.is_empty() => { + Some((values.iter().sum::() / values.len() as f64).to_string()) + } + "COUNT" => Some(values.len().to_string()), + "MIN" if !values.is_empty() => { + Some(values.into_iter().fold(f64::INFINITY, f64::min).to_string()) + } + "MAX" if !values.is_empty() => Some( + values + .into_iter() + .fold(f64::NEG_INFINITY, f64::max) + .to_string(), + ), + _ => None, + } +} + fn fallback_content_centers( bounds: [f32; 4], sizes: &[(f32, f32)], @@ -312,12 +624,12 @@ fn fallback_content_centers( fn content_display_value( document: &acadrust::CadDocument, - table_handle: acadrust::Handle, + table: &Table, content: &acadrust::entities::table::CellContent, ) -> String { if let Some(handle) = content.field_handle { if let Some(value) = - crate::entities::field::resolve_handle(document, handle, table_handle) + crate::entities::field::resolve_handle(document, handle, table.common.handle) { return value; } @@ -331,7 +643,8 @@ fn content_display_value( } } } - format_cell_value(&content.value) + let value = format_cell_value(&content.value); + evaluate_table_formula(table, &value).unwrap_or(value) } fn resolved_content_geometry( @@ -406,10 +719,7 @@ pub(crate) fn block_cell_inserts( _ => None, }) }); - let flow = if matches!( - table_style.map(|style| style.flow_direction), - Some(acadrust::objects::TableFlowDirection::Up) - ) { + let flow = if resolved_flow_up(table, table_style) { -down } else { down @@ -837,8 +1147,8 @@ impl RenderConvertible for Table { _ => None, }) }); - let title_suppressed = table_style.map(|t| t.title_suppressed).unwrap_or(false); - let header_suppressed = table_style.map(|t| t.header_suppressed).unwrap_or(false); + let title_suppressed = resolved_title_suppressed(self, table_style); + let header_suppressed = resolved_header_suppressed(self, table_style); let font_for_handle = |handle: Option| -> Option { handle.and_then(|h| lookup_style(h)).and_then(|s| { @@ -877,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), } }; @@ -977,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, }); @@ -1037,13 +1350,7 @@ impl RenderConvertible for Table { } } -/// Coloured synthesis render for tables WITHOUT a baked block (e.g. tables -/// created in-app). Emits one `WireModel` per distinct colour for cell fills, -/// per-cell text, and grid borders, honouring every `TableStyle` / -/// `RowCellStyle` / per-cell `CellStyle` field: fill colour + enable, text -/// colour, border type/weight/colour/visibility (incl. inside borders), -/// margins, and flow direction. Imported tables keep using AutoCAD's baked -/// block (see scene/mod.rs). +/// Builds colored geometry for tables without a stored display block. pub fn tessellate_table( tab: &Table, document: &acadrust::CadDocument, @@ -1101,20 +1408,19 @@ pub fn tessellate_table( _ => None, }) }); - let flow_up = matches!( - table_style.map(|t| t.flow_direction), - Some(acadrust::objects::TableFlowDirection::Up) - ); + let flow_up = resolved_flow_up(tab, table_style); let v_flow = if flow_up { -v_down } else { v_down }; let (col_offsets, row_offsets) = table_offsets(tab, anno_scale); - let title_suppressed = table_style.map(|t| t.title_suppressed).unwrap_or(false); - let header_suppressed = table_style.map(|t| t.header_suppressed).unwrap_or(false); - let h_margin = table_style - .map(|t| t.horizontal_margin as f32) - .unwrap_or(0.0) * anno_scale; - let v_margin = table_style.map(|t| t.vertical_margin as f32).unwrap_or(0.0) * anno_scale; + let title_suppressed = resolved_title_suppressed(tab, table_style); + let header_suppressed = resolved_header_suppressed(tab, table_style); + let top_label_rows = (usize::from(!title_suppressed) + usize::from(!header_suppressed)) + .min(tab.rows.len()); + let bottom_label_rows = usize::from(!tab.rows.is_empty()); + let (horizontal_margin, vertical_margin) = resolved_table_margins(tab, table_style); + let h_margin = horizontal_margin as f32 * anno_scale; + let v_margin = vertical_margin as f32 * anno_scale; let lookup_style = |hh: acadrust::Handle| -> Option<&acadrust::tables::TextStyle> { document.text_styles.iter().find(|s| s.handle == hh) @@ -1150,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), } }; @@ -1209,8 +1516,17 @@ pub fn tessellate_table( else { continue; }; - let (origin, row_top) = - break_frame_for_row(tab, ri, h, v_flow, &row_offsets, anno_scale); + let frames = break_frames_for_row( + tab, + ri, + h, + v_flow, + &row_offsets, + anno_scale, + top_label_rows, + bottom_label_rows, + ); + for (origin, row_top) in frames { let merged_height = row_offsets .get(row_end + 1) .copied() @@ -1228,13 +1544,6 @@ pub fn tessellate_table( let tr = origin + h * col_right + v_flow * row_top; let br_ = origin + h * col_right + v_flow * row_bot; let bl = origin + h * col_left + v_flow * row_bot; - let cell_style = cell - .style - .as_ref() - .or(row.style.as_ref()) - .or_else(|| tab.columns.get(ci).and_then(|column| column.style.as_ref())) - .or(tab.base_style.as_ref()); - // ── Fill ────────────────────────────────────────────────────── let fill_style = style_for_property( tab, @@ -1264,7 +1573,13 @@ pub fn tessellate_table( // ── Borders (per edge: cell override → row style → default) ─── // (top, right, bottom, left) let edge = |which: u8| -> (bool, [f32; 4], f32) { - if let Some(cs) = cell_style { + let edge_flag = match which { + 0 => acadrust::entities::table::CellEdgeFlags::TOP, + 1 => acadrust::entities::table::CellEdgeFlags::RIGHT, + 2 => acadrust::entities::table::CellEdgeFlags::BOTTOM, + _ => acadrust::entities::table::CellEdgeFlags::LEFT, + }; + if let Some(cs) = style_for_border(tab, row, ci, cell, edge_flag) { let b = match which { 0 => &cs.top_border, 1 => &cs.right_border, @@ -1326,8 +1641,7 @@ pub fn tessellate_table( .iter() .enumerate() .filter_map(|(index, content)| { - let text = - content_display_value(document, tab.common.handle, content); + let text = content_display_value(document, tab, content); (!text.is_empty()).then_some((index, content, text)) }) .collect(); @@ -1610,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, }); @@ -1660,6 +1976,7 @@ pub fn tessellate_table( } } } + } } } @@ -1669,6 +1986,7 @@ pub fn tessellate_table( WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -1740,173 +2058,756 @@ pub fn tessellate_table( impl Grippable for Table { fn grips(&self) -> Vec { - vec![square_grip( + let origin = glam::DVec3::new( + self.insertion_point.x, + self.insertion_point.y, + self.insertion_point.z, + ); + let horizontal = glam::DVec3::new( + self.horizontal_direction.x, + self.horizontal_direction.y, + self.horizontal_direction.z, + ) + .normalize_or(glam::DVec3::X); + let normal = glam::DVec3::new(self.normal.x, self.normal.y, self.normal.z) + .normalize_or(glam::DVec3::Z); + let down = horizontal.cross(normal).normalize_or(glam::DVec3::NEG_Y); + let width = self.total_width(); + let height = self.total_height(); + let mut grips = vec![square_grip( 0, - glam::DVec3::new( - self.insertion_point.x, - self.insertion_point.y, - self.insertion_point.z, - ), - )] + origin, + )]; + grips.push(square_grip(1, origin + horizontal * width)); + grips.push(square_grip(2, origin + down * height)); + let mut offset = 0.0; + for (column, definition) in self.columns.iter().enumerate() { + offset += definition.width; + if column + 1 < self.columns.len() { + grips.push(square_grip( + 100 + column, + origin + horizontal * offset + down * (height * 0.5), + )); + } + } + offset = 0.0; + for (row, definition) in self.rows.iter().enumerate() { + offset += definition.height; + if row + 1 < self.rows.len() { + grips.push(square_grip( + 1000 + row, + origin + horizontal * (width * 0.5) + down * offset, + )); + } + } + for (index, data) in self.break_data.iter().enumerate() { + grips.push(square_grip( + 2000 + index, + origin + glam::DVec3::new(data.position.x, data.position.y, data.position.z), + )); + } + grips } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { + if let GripApply::Translate(delta) = apply { + if let Some(grip) = self.grips().into_iter().find(|grip| grip.id == grip_id) { + self.apply_grip(grip_id, GripApply::Absolute(grip.world + delta)); + } + return; + } + let GripApply::Absolute(point) = apply else { + return; + }; if grip_id == 0 { - match apply { - GripApply::Translate(d) => { - self.insertion_point.x += d.x as f64; - self.insertion_point.y += d.y as f64; - self.insertion_point.z += d.z as f64; + self.insertion_point.x = point.x; + self.insertion_point.y = point.y; + self.insertion_point.z = point.z; + return; + } + let origin = glam::DVec3::new( + self.insertion_point.x, + self.insertion_point.y, + self.insertion_point.z, + ); + let horizontal = glam::DVec3::new( + self.horizontal_direction.x, + self.horizontal_direction.y, + self.horizontal_direction.z, + ) + .normalize_or(glam::DVec3::X); + let normal = glam::DVec3::new(self.normal.x, self.normal.y, self.normal.z) + .normalize_or(glam::DVec3::Z); + let down = horizontal.cross(normal).normalize_or(glam::DVec3::NEG_Y); + if grip_id == 1 { + let width = (point - origin).dot(horizontal).max(1.0e-6); + let current = self.total_width(); + if current > 1.0e-12 { + for column in &mut self.columns { + column.width *= width / current; } - GripApply::Absolute(p) => { - self.insertion_point.x = p.x as f64; - self.insertion_point.y = p.y as f64; - self.insertion_point.z = p.z as f64; + } + } else if grip_id == 2 { + let height = (point - origin).dot(down).max(1.0e-6); + let current = self.total_height(); + if current > 1.0e-12 { + for row in &mut self.rows { + row.height *= height / current; } } + } else if (100..1000).contains(&grip_id) { + let column = grip_id - 100; + let before: f64 = self.columns.iter().take(column).map(|item| item.width).sum(); + if let Some(definition) = self.columns.get_mut(column) { + definition.width = ((point - origin).dot(horizontal) - before).max(1.0e-6); + } + } else if (1000..2000).contains(&grip_id) { + let row = grip_id - 1000; + let before: f64 = self.rows.iter().take(row).map(|item| item.height).sum(); + if let Some(definition) = self.rows.get_mut(row) { + definition.height = ((point - origin).dot(down) - before).max(1.0e-6); + } + } else if grip_id >= 2000 { + if let Some(data) = self.break_data.get_mut(grip_id - 2000) { + let offset = point - origin; + data.position = acadrust::types::Vector3::new(offset.x, offset.y, offset.z); + } } } } impl PropertyEditable for Table { - fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { + fn geometry_properties(&self, text_style_names: &[String]) -> Vec { use crate::entities::common::edit_prop as edit; - use acadrust::entities::table::BreakOptionFlags; - - let fmt_h = |oh: &Option| -> String { - match oh { - Some(h) if !h.is_null() => format!("{:X}", h.value()), - _ => "(none)".to_string(), - } - }; - let toggle = |label: &str, field: &'static str, b: bool| -> Property { + use acadrust::entities::table::{BreakOptionFlags, CellStateFlags, CellStylePropertyFlags}; + let bool_text = |value: bool| if value { t!("Yes") } else { t!("No") }.into_owned(); + let toggle = |label: &str, field: &'static str, value: bool, enabled: bool| -> Property { Property { label: label.into(), field, - value: PropValue::BoolToggle { field, value: b }, + value: if enabled { + PropValue::BoolToggle { field, value } + } else { + PropValue::ReadOnly(bool_text(value)) + }, + } + }; + let choice = |label: &str, + field: &'static str, + selected: String, + options: Vec, + enabled: bool| + -> Property { + Property { + label: label.into(), + field, + value: if enabled { + PropValue::Choice { selected, options } + } else { + PropValue::ReadOnly(selected) + }, + } + }; + let number = |label: &str, field: &'static str, value: f64, enabled: bool| -> Property { + if enabled { + edit(label, field, value) + } else { + ro(label, field, crate::entities::common::format_length(value)) + } + }; + let text = |label: &str, field: &'static str, value: String, enabled: bool| -> Property { + Property { + label: label.into(), + field, + value: if enabled { + PropValue::PlainText(value) + } else { + PropValue::ReadOnly(value) + }, + } + }; + let color_text = |color: acadrust::types::Color| match color { + acadrust::types::Color::None => "None".to_string(), + acadrust::types::Color::ByLayer => "ByLayer".to_string(), + acadrust::types::Color::ByBlock => "ByBlock".to_string(), + acadrust::types::Color::Index(index) => index.to_string(), + acadrust::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"), + }; + let color = |label: &str, + field: &'static str, + value: acadrust::types::Color, + enabled: bool| + -> Property { + Property { + label: label.into(), + field, + value: if enabled { + PropValue::ColorChoice(value) + } else { + PropValue::ReadOnly(color_text(value)) + }, } }; // Direction = angle of the horizontal direction vector in the XY plane. let direction_deg = (self.horizontal_direction.y.atan2(self.horizontal_direction.x)).to_degrees(); - let mut content_count = 0usize; - let mut block_content_count = 0usize; - let mut field_content_count = 0usize; - let mut linked_cell_count = 0usize; - for row in &self.rows { - for cell in &row.cells { - content_count += cell.contents.len(); - linked_cell_count += usize::from(cell.has_linked_data); - for content in &cell.contents { - block_content_count += usize::from(content.block_handle.is_some()); - field_content_count += usize::from(content.field_handle.is_some()); + let break_height = self.break_data.first().map(|data| data.height).unwrap_or(0.0); + let breaks_enabled = self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS); + let manual_positions = self + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS); + let manual_heights = self + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_HEIGHTS); + let cell_count = self.rows.len().saturating_mul(self.columns.len()); + let cell_index = prop_current_cell().min(cell_count.saturating_sub(1)); + let cell_row = if self.columns.is_empty() { + 0 + } else { + cell_index / self.columns.len() + }; + let cell_column = if self.columns.is_empty() { + 0 + } else { + cell_index % self.columns.len() + }; + let table_row = self.rows.get(cell_row); + let cell = self.cell(cell_row, cell_column); + let row_style = table_row.and_then(|row| row.style.as_ref()); + let column_style = self + .columns + .get(cell_column) + .and_then(|column| column.style.as_ref()); + let style_for = |property| { + table_row.and_then(|row| { + cell.and_then(|cell| { + style_for_property(self, row, cell_column, cell, property) + }) + }) + }; + let alignment_style = style_for(CellStylePropertyFlags::ALIGNMENT); + let alignment = match alignment_style.map(|style| style.alignment).unwrap_or(5) { + 1 => "Top Left", + 2 => "Top Center", + 3 => "Top Right", + 4 => "Middle Left", + 6 => "Middle Right", + 7 => "Bottom Left", + 8 => "Bottom Center", + 9 => "Bottom Right", + _ => "Middle Center", + }; + let state = cell.map(|cell| cell.state).unwrap_or_default(); + let content_editable = !state.intersects( + CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY, + ); + let format_editable = !state.intersects( + CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY, + ); + let immutable_lock = state.intersects( + CellStateFlags::CONTENT_READ_ONLY | CellStateFlags::FORMAT_READ_ONLY, + ); + let cell_locked = state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY + | CellStateFlags::FORMAT_LOCKED + | CellStateFlags::FORMAT_READ_ONLY, + ); + let table_flow_up = self + .legacy_style_override + .as_ref() + .and_then(|style| style.flow_direction) + .map(|flow| flow != 0) + .unwrap_or_else(|| { + self.base_style.as_ref().is_some_and(|style| { + style + .property_flags + .contains(CellStylePropertyFlags::FLOW_DIRECTION_BOTTOM_TO_TOP) + }) + }); + let legacy = self.legacy_style_override.as_ref(); + let title_suppressed = legacy + .and_then(|style| style.title_suppressed) + .unwrap_or(false); + let header_suppressed = legacy + .and_then(|style| style.header_suppressed) + .unwrap_or(false); + let horizontal_margin = legacy + .and_then(|style| style.horizontal_cell_margin) + .or_else(|| self.base_style.as_ref().map(|style| style.margin_left)) + .unwrap_or(0.06); + let vertical_margin = legacy + .and_then(|style| style.vertical_cell_margin) + .or_else(|| self.base_style.as_ref().map(|style| style.margin_top)) + .unwrap_or(0.06); + let uniform_column_width = self.columns.first().map(|column| column.width).unwrap_or(0.0); + let columns_uniform = self + .columns + .iter() + .all(|column| (column.width - uniform_column_width).abs() <= 1.0e-9); + let uniform_row_height = self.rows.first().map(|row| row.height).unwrap_or(0.0); + let rows_uniform = self + .rows + .iter() + .all(|row| (row.height - uniform_row_height).abs() <= 1.0e-9); + let uniform_number = |label: &str, + field: &'static str, + value: f64, + uniform: bool| + -> Property { + if uniform { + edit(label, field, value) + } else { + Property { + label: label.into(), + field, + value: PropValue::PlainText(t!("Varies").into_owned()), } } - } - let break_heights = self - .break_data + }; + let override_count = self + .rows .iter() - .map(|data| format!("{:.4}", data.height)) - .collect::>() - .join(", "); + .filter(|row| row.style.is_some()) + .count() + + self + .columns + .iter() + .filter(|column| column.style.is_some()) + .count() + + self + .rows + .iter() + .flat_map(|row| row.cells.iter()) + .filter(|cell| cell.style.is_some()) + .count() + + usize::from(self.base_style.is_some() || self.legacy_style_override.is_some()); - vec![ + let mut sections = vec![ PropSection { title: t!("Table").into_owned(), props: vec![ - ro( - t!("Table style").as_ref(), - "tbl_style_handle", - fmt_h(&self.table_style_handle), + ro(t!("Table style").as_ref(), "tbl_style_handle", "Standard"), + toggle( + t!("Title suppressed").as_ref(), + "tbl_title_suppressed", + title_suppressed, + true, ), - ro(t!("Rows").as_ref(), "tbl_rows", self.rows.len().to_string()), - ro(t!("Columns").as_ref(), "tbl_cols", self.columns.len().to_string()), - ro(t!("Contents").as_ref(), "tbl_contents", content_count.to_string()), - ro( - t!("Block contents").as_ref(), - "tbl_block_contents", - block_content_count.to_string(), + toggle( + t!("Header suppressed").as_ref(), + "tbl_header_suppressed", + header_suppressed, + true, + ), + choice( + t!("Flow direction").as_ref(), + "tbl_flow_direction", + if table_flow_up { "Up" } else { "Down" }.to_string(), + vec!["Down".into(), "Up".into()], + true, + ), + edit(t!("Direction").as_ref(), "tbl_direction", direction_deg), + edit(t!("Rows").as_ref(), "tbl_rows", self.rows.len() as f64), + edit(t!("Columns").as_ref(), "tbl_cols", self.columns.len() as f64), + uniform_number( + t!("Column width").as_ref(), + "tbl_column_width", + uniform_column_width, + columns_uniform, + ), + uniform_number( + t!("Row height").as_ref(), + "tbl_row_height", + uniform_row_height, + rows_uniform, + ), + edit(t!("Table width").as_ref(), "tbl_width", self.total_width()), + edit(t!("Table height").as_ref(), "tbl_height", self.total_height()), + edit( + t!("Horizontal cell margin").as_ref(), + "tbl_horizontal_margin", + horizontal_margin, + ), + edit( + t!("Vertical cell margin").as_ref(), + "tbl_vertical_margin", + vertical_margin, ), ro( - t!("Field contents").as_ref(), - "tbl_field_contents", - field_content_count.to_string(), - ), - ro( - t!("Merged ranges").as_ref(), - "tbl_merged_ranges", - self.merged_ranges.len().to_string(), - ), - ro( - t!("Linked cells").as_ref(), - "tbl_linked_cells", - linked_cell_count.to_string(), - ), - ro(t!("Direction").as_ref(), "tbl_direction", format!("{:.4}", direction_deg)), - ro( - t!("Table width").as_ref(), - "tbl_width", - format!("{:.4}", self.total_width()), - ), - ro( - t!("Table height").as_ref(), - "tbl_height", - format!("{:.4}", self.total_height()), + t!("Table overrides").as_ref(), + "tbl_overrides", + if override_count == 0 { + t!("None").into_owned() + } else { + override_count.to_string() + }, ), ], }, + PropSection { + title: t!("Geometry").into_owned(), + props: vec![ + edit(t!("Insertion X").as_ref(), "tbl_insert_x", self.insertion_point.x), + edit(t!("Insertion Y").as_ref(), "tbl_insert_y", self.insertion_point.y), + edit(t!("Insertion Z").as_ref(), "tbl_insert_z", self.insertion_point.z), + ro(t!("Normal X").as_ref(), "tbl_normal_x", format!("{:.4}", self.normal.x)), + ro(t!("Normal Y").as_ref(), "tbl_normal_y", format!("{:.4}", self.normal.y)), + ro(t!("Normal Z").as_ref(), "tbl_normal_z", format!("{:.4}", self.normal.z)), + ], + }, + ]; + + if prop_current_cell_active() { + let cell_type = match cell.map(|cell| cell.cell_type) { + Some(acadrust::entities::table::CellType::Block) => "Block", + _ => "Text", + }; + let data_type = cell + .and_then(|cell| cell.contents.first()) + .map(|content| match content.value.value_type { + acadrust::entities::table::CellValueType::Long => "Integer", + acadrust::entities::table::CellValueType::Double => "Decimal", + acadrust::entities::table::CellValueType::Date => "Date", + acadrust::entities::table::CellValueType::Point2D => "Point 2D", + acadrust::entities::table::CellValueType::Point3D => "Point 3D", + acadrust::entities::table::CellValueType::Handle => "Handle", + _ => "Text", + }) + .unwrap_or("Text"); + let style_source = if cell.and_then(|cell| cell.style.as_ref()).is_some() { + "Cell override" + } else if row_style.is_some() { + "Row override" + } else if column_style.is_some() { + "Column override" + } else { + "Table style" + }; + let text_style = style_for(CellStylePropertyFlags::TEXT_STYLE) + .map(|style| style.text_style_name.clone()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "Standard".to_string()); + let mut available_text_styles = text_style_names.to_vec(); + if !available_text_styles + .iter() + .any(|name| name.eq_ignore_ascii_case(&text_style)) + { + available_text_styles.insert(0, text_style.clone()); + } + let rotation = style_for(CellStylePropertyFlags::ROTATION) + .map(|style| style.rotation) + .or_else(|| cell.map(|cell| cell.rotation)) + .unwrap_or(0.0) + .to_degrees(); + sections.push(PropSection { + title: t!("Cell").into_owned(), + props: vec![ + Property { + label: t!("Current cell").into_owned(), + field: "tbl_current_cell", + value: PropValue::Stepper { + field: "tbl_current_cell", + display: format!( + "{},{} ({} / {})", + cell_row, + cell_column, + cell_index.saturating_add(1), + cell_count + ), + }, + }, + ro(t!("Row").as_ref(), "tbl_cell_row", cell_row.to_string()), + ro( + t!("Column").as_ref(), + "tbl_cell_column", + cell_column.to_string(), + ), + ro(t!("Cell style").as_ref(), "tbl_cell_style", style_source), + choice( + t!("Cell type").as_ref(), + "tbl_cell_type", + cell_type.to_string(), + vec!["Text".into(), "Block".into()], + content_editable, + ), + text( + t!("Contents").as_ref(), + "tbl_cell_text", + cell.map(|cell| cell.text_value().to_string()).unwrap_or_default(), + content_editable, + ), + choice( + t!("Alignment").as_ref(), + "tbl_cell_alignment", + alignment.to_string(), + vec![ + "Top Left".into(), + "Top Center".into(), + "Top Right".into(), + "Middle Left".into(), + "Middle Center".into(), + "Middle Right".into(), + "Bottom Left".into(), + "Bottom Center".into(), + "Bottom Right".into(), + ], + format_editable, + ), + choice( + t!("Text style").as_ref(), + "tbl_cell_text_style", + text_style, + available_text_styles, + format_editable, + ), + choice( + t!("Text rotation").as_ref(), + "tbl_cell_rotation", + format!("{rotation:.0}"), + vec!["0".into(), "90".into(), "180".into(), "270".into()], + format_editable, + ), + number( + t!("Text height").as_ref(), + "tbl_cell_text_height", + style_for(CellStylePropertyFlags::TEXT_HEIGHT) + .map(|style| style.text_height) + .unwrap_or(0.18), + format_editable, + ), + color( + t!("Content color").as_ref(), + "tbl_cell_content_color", + style_for(CellStylePropertyFlags::CONTENT_COLOR) + .map(|style| style.content_color) + .unwrap_or(acadrust::types::Color::ByBlock), + format_editable, + ), + color( + t!("Background color").as_ref(), + "tbl_cell_background_color", + style_for(CellStylePropertyFlags::BACKGROUND_COLOR) + .map(|style| style.background_color) + .unwrap_or(acadrust::types::Color::ByBlock), + format_editable, + ), + choice( + t!("Data type").as_ref(), + "tbl_cell_data_type", + data_type.to_string(), + vec![ + "Text".into(), + "Integer".into(), + "Decimal".into(), + "Date".into(), + "Point 2D".into(), + "Point 3D".into(), + "Handle".into(), + ], + format_editable, + ), + text( + t!("Data format").as_ref(), + "tbl_cell_format", + style_for(CellStylePropertyFlags::DATA_FORMAT) + .map(|style| style.value_format.clone()) + .unwrap_or_default(), + format_editable, + ), + toggle( + t!("Background fill").as_ref(), + "tbl_cell_fill", + style_for(CellStylePropertyFlags::BACKGROUND_COLOR) + .is_some_and(|style| style.fill_enabled), + format_editable, + ), + number( + t!("Left margin").as_ref(), + "tbl_cell_margin_left", + style_for(CellStylePropertyFlags::MARGIN_LEFT) + .map(|style| style.margin_left) + .unwrap_or(horizontal_margin), + format_editable, + ), + number( + t!("Top margin").as_ref(), + "tbl_cell_margin_top", + style_for(CellStylePropertyFlags::MARGIN_TOP) + .map(|style| style.margin_top) + .unwrap_or(vertical_margin), + format_editable, + ), + number( + t!("Right margin").as_ref(), + "tbl_cell_margin_right", + style_for(CellStylePropertyFlags::MARGIN_RIGHT) + .map(|style| style.margin_right) + .unwrap_or(horizontal_margin), + format_editable, + ), + number( + t!("Bottom margin").as_ref(), + "tbl_cell_margin_bottom", + style_for(CellStylePropertyFlags::MARGIN_BOTTOM) + .map(|style| style.margin_bottom) + .unwrap_or(vertical_margin), + format_editable, + ), + toggle( + t!("Top border").as_ref(), + "tbl_cell_border_top", + table_row + .and_then(|row| { + cell.and_then(|cell| { + style_for_border( + self, + row, + cell_column, + cell, + acadrust::entities::table::CellEdgeFlags::TOP, + ) + }) + }) + .is_none_or(|style| !style.top_border.invisible), + format_editable, + ), + toggle( + t!("Right border").as_ref(), + "tbl_cell_border_right", + table_row + .and_then(|row| { + cell.and_then(|cell| { + style_for_border( + self, + row, + cell_column, + cell, + acadrust::entities::table::CellEdgeFlags::RIGHT, + ) + }) + }) + .is_none_or(|style| !style.right_border.invisible), + format_editable, + ), + toggle( + t!("Bottom border").as_ref(), + "tbl_cell_border_bottom", + table_row + .and_then(|row| { + cell.and_then(|cell| { + style_for_border( + self, + row, + cell_column, + cell, + acadrust::entities::table::CellEdgeFlags::BOTTOM, + ) + }) + }) + .is_none_or(|style| !style.bottom_border.invisible), + format_editable, + ), + toggle( + t!("Left border").as_ref(), + "tbl_cell_border_left", + table_row + .and_then(|row| { + cell.and_then(|cell| { + style_for_border( + self, + row, + cell_column, + cell, + acadrust::entities::table::CellEdgeFlags::LEFT, + ) + }) + }) + .is_none_or(|style| !style.left_border.invisible), + format_editable, + ), + toggle( + t!("Locked").as_ref(), + "tbl_cell_locked", + cell_locked, + !immutable_lock, + ), + ], + }); + } + + sections.push( PropSection { title: t!("Table Breaks").into_owned(), props: vec![ toggle( t!("Enabled").as_ref(), "tbl_break_enabled", - self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS), + breaks_enabled, + true, ), - ro( + choice( t!("Direction").as_ref(), "tbl_break_direction", - format!("{:?}", self.break_flow_direction), + match self.break_flow_direction { + acadrust::entities::table::BreakFlowDirection::Right => "Right", + acadrust::entities::table::BreakFlowDirection::Left => "Left", + acadrust::entities::table::BreakFlowDirection::Vertical => "Down", + } + .to_string(), + vec!["Right".into(), "Left".into(), "Down".into()], + breaks_enabled && !manual_positions, ), toggle( t!("Repeat top labels").as_ref(), "tbl_break_repeat_top", self.break_options .contains(BreakOptionFlags::REPEAT_TOP_LABELS), + breaks_enabled, ), toggle( t!("Repeat bottom labels").as_ref(), "tbl_break_repeat_bottom", self.break_options .contains(BreakOptionFlags::REPEAT_BOTTOM_LABELS), + breaks_enabled, ), toggle( t!("Manual positions").as_ref(), "tbl_break_manual_positions", - self.break_options - .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS), + manual_positions, + breaks_enabled, ), toggle( t!("Manual heights").as_ref(), "tbl_break_manual_heights", - self.break_options - .contains(BreakOptionFlags::ALLOW_MANUAL_HEIGHTS), + manual_heights, + breaks_enabled, ), - ro( - t!("Segments").as_ref(), - "tbl_break_segments", - self.break_ranges.len().max(self.break_data.len()).to_string(), + number( + t!("Maximum height").as_ref(), + "tbl_break_height", + break_height, + breaks_enabled && !manual_heights, + ), + number( + t!("Spacing").as_ref(), + "tbl_break_spacing", + self.break_spacing, + breaks_enabled && !manual_positions, ), - ro(t!("Break heights").as_ref(), "tbl_break_height", break_heights), - edit(t!("Spacing").as_ref(), "tbl_break_spacing", self.break_spacing), ], }, - ] + ); + + sections } fn apply_geom_prop(&mut self, field: &str, value: &str) { use crate::entities::common::parse_f64; - use acadrust::entities::table::BreakOptionFlags; + use acadrust::entities::table::{ + BreakOptionFlags, CellStateFlags, CellStyle, CellStylePropertyFlags, + }; let flag = match field { "tbl_break_enabled" => Some(BreakOptionFlags::ENABLE_BREAKS), "tbl_break_repeat_top" => Some(BreakOptionFlags::REPEAT_TOP_LABELS), @@ -1916,18 +2817,460 @@ impl PropertyEditable for Table { _ => None, }; if let Some(flag) = flag { + if flag != BreakOptionFlags::ENABLE_BREAKS + && !self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS) + { + return; + } let on = if value == "toggle" { !self.break_options.contains(flag) } else { value == "true" }; self.break_options.set(flag, on); + self.break_ranges.clear(); return; } - if field == "tbl_break_spacing" { - if let Some(v) = parse_f64(value) { - self.break_spacing = v; + match field { + "tbl_title_suppressed" | "tbl_header_suppressed" => { + let enabled = if value == "toggle" { + let current = if field == "tbl_title_suppressed" { + self.legacy_style_override + .as_ref() + .and_then(|style| style.title_suppressed) + .unwrap_or(false) + } else { + self.legacy_style_override + .as_ref() + .and_then(|style| style.header_suppressed) + .unwrap_or(false) + }; + !current + } else { + value == "true" + }; + let style = self.legacy_style_override.get_or_insert_with(Default::default); + if field == "tbl_title_suppressed" { + style.flags |= 0x0001; + style.title_suppressed = Some(enabled); + } else { + style.flags |= 0x0002; + style.header_suppressed = Some(enabled); + } + self.override_flag = true; + return; } + "tbl_flow_direction" => { + let up = value.trim().eq_ignore_ascii_case("up"); + let style = self.base_style.get_or_insert_with(Default::default); + style + .property_flags + .set(CellStylePropertyFlags::FLOW_DIRECTION_BOTTOM_TO_TOP, up); + let legacy = self.legacy_style_override.get_or_insert_with(Default::default); + legacy.flags |= 0x0004; + legacy.flow_direction = Some(if up { 1 } else { 0 }); + self.override_flag = true; + return; + } + _ => {} + } + let cell_index = prop_current_cell(); + let columns = self.columns.len(); + let cell_position = (columns > 0).then_some((cell_index / columns, cell_index % columns)); + if let Some((row, column)) = cell_position { + let state = self + .cell(row, column) + .map(|cell| cell.state) + .unwrap_or_default(); + let content_editable = !state.intersects( + CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY, + ); + let format_editable = !state.intersects( + CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY, + ); + match field { + "tbl_cell_text" => { + if !content_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + cell.set_text(value); + } + return; + } + "tbl_cell_type" => { + if !content_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + cell.cell_type = if value.trim().eq_ignore_ascii_case("block") { + acadrust::entities::table::CellType::Block + } else { + acadrust::entities::table::CellType::Text + }; + } + return; + } + "tbl_cell_alignment" => { + if !format_editable { + return; + } + let alignment = match value.trim().to_ascii_uppercase().as_str() { + "TOP LEFT" => 1, + "TOP CENTER" => 2, + "TOP RIGHT" => 3, + "MIDDLE LEFT" => 4, + "MIDDLE RIGHT" => 6, + "BOTTOM LEFT" => 7, + "BOTTOM CENTER" => 8, + "BOTTOM RIGHT" => 9, + _ => 5, + }; + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); + style.alignment = alignment; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::ALIGNMENT, + ); + } + return; + } + "tbl_cell_text_style" => { + if !format_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); + style.text_style_name = value.trim().to_string(); + style + .property_flags + .insert(CellStylePropertyFlags::TEXT_STYLE); + } + return; + } + "tbl_cell_rotation" => { + if !format_editable { + return; + } + if let Some(rotation) = parse_f64(value) { + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); + style.rotation = rotation.to_radians(); + style + .property_flags + .insert(CellStylePropertyFlags::ROTATION); + } + } + return; + } + "tbl_cell_data_type" => { + if !format_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + if cell.contents.is_empty() { + cell.set_text(""); + } + if let Some(content) = cell.contents.first_mut() { + content.value.value_type = match value.trim().to_ascii_uppercase().as_str() { + "INTEGER" => acadrust::entities::table::CellValueType::Long, + "DECIMAL" => acadrust::entities::table::CellValueType::Double, + "DATE" => acadrust::entities::table::CellValueType::Date, + "POINT 2D" => acadrust::entities::table::CellValueType::Point2D, + "POINT 3D" => acadrust::entities::table::CellValueType::Point3D, + "HANDLE" => acadrust::entities::table::CellValueType::Handle, + _ => acadrust::entities::table::CellValueType::String, + }; + content.value.raw_type_code = content.value.value_type as i32; + } + let style = cell.style.get_or_insert_with(CellStyle::new); + style + .property_flags + .insert(CellStylePropertyFlags::DATA_TYPE); + } + return; + } + "tbl_cell_format" => { + if !format_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); + style.value_format = value.to_string(); + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::DATA_FORMAT, + ); + } + return; + } + "tbl_cell_fill" => { + if !format_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); + style.fill_enabled = if value == "toggle" { + !style.fill_enabled + } else { + value == "true" + }; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::BACKGROUND_COLOR, + ); + } + return; + } + "tbl_cell_border_top" + | "tbl_cell_border_right" + | "tbl_cell_border_bottom" + | "tbl_cell_border_left" => { + if !format_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + use acadrust::entities::table::{ + BorderPropertyFlags, CellEdgeFlags, + }; + let style = cell.style.get_or_insert_with(CellStyle::new); + let (border, edge) = match field { + "tbl_cell_border_top" => (&mut style.top_border, CellEdgeFlags::TOP), + "tbl_cell_border_right" => { + (&mut style.right_border, CellEdgeFlags::RIGHT) + } + "tbl_cell_border_bottom" => { + (&mut style.bottom_border, CellEdgeFlags::BOTTOM) + } + _ => (&mut style.left_border, CellEdgeFlags::LEFT), + }; + let visible = if value == "toggle" { + border.invisible + } else { + value == "true" + }; + border.invisible = !visible; + border.override_flags.insert(BorderPropertyFlags::INVISIBILITY); + style.applied_border_edges.insert(edge); + } + return; + } + "tbl_cell_locked" => { + if state.intersects( + CellStateFlags::CONTENT_READ_ONLY | CellStateFlags::FORMAT_READ_ONLY, + ) { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + let locked = if value == "toggle" { + !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED | CellStateFlags::FORMAT_LOCKED, + ) + } else { + value == "true" + }; + cell.state.set(CellStateFlags::CONTENT_LOCKED, locked); + cell.state.set(CellStateFlags::FORMAT_LOCKED, locked); + } + return; + } + _ => {} + } + } + let Some(number) = parse_f64(value) else { + if field == "tbl_break_direction" { + if !self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS) + || self + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS) + { + return; + } + self.break_flow_direction = match value.trim().to_ascii_uppercase().as_str() { + "LEFT" => acadrust::entities::table::BreakFlowDirection::Left, + "DOWN" | "VERTICAL" => { + acadrust::entities::table::BreakFlowDirection::Vertical + } + _ => acadrust::entities::table::BreakFlowDirection::Right, + }; + self.break_ranges.clear(); + } + return; + }; + match field { + "tbl_insert_x" => self.insertion_point.x = number, + "tbl_insert_y" => self.insertion_point.y = number, + "tbl_insert_z" => self.insertion_point.z = number, + "tbl_direction" => { + let radians = number.to_radians(); + self.horizontal_direction.x = radians.cos(); + self.horizontal_direction.y = radians.sin(); + self.horizontal_direction.z = 0.0; + } + "tbl_rows" => { + let requested = number.round().max(1.0) as usize; + while self.rows.len() < requested { + self.add_row(); + } + while self.rows.len() > requested { + self.remove_row(self.rows.len() - 1); + } + self.break_ranges.clear(); + } + "tbl_cols" => { + let requested = number.round().max(1.0) as usize; + let width = self.columns.last().map(|column| column.width).unwrap_or(2.0); + while self.columns.len() < requested { + self.add_column(width); + } + while self.columns.len() > requested { + self.remove_column(self.columns.len() - 1); + } + self.break_ranges.clear(); + } + "tbl_column_width" if number > 0.0 => { + for column in &mut self.columns { + column.width = number; + } + self.break_ranges.clear(); + } + "tbl_row_height" if number > 0.0 => { + for row in &mut self.rows { + row.height = number; + } + self.break_ranges.clear(); + } + "tbl_width" if number > 0.0 => { + let current = self.total_width(); + if current > 1.0e-12 { + for column in &mut self.columns { + column.width *= number / current; + } + } + self.break_ranges.clear(); + } + "tbl_height" if number > 0.0 => { + let current = self.total_height(); + if current > 1.0e-12 { + for row in &mut self.rows { + row.height *= number / current; + } + } + self.break_ranges.clear(); + } + "tbl_horizontal_margin" if number >= 0.0 => { + let style = self.base_style.get_or_insert_with(Default::default); + style.margin_left = number; + style.margin_right = number; + style.property_flags.insert( + CellStylePropertyFlags::MARGIN_LEFT | CellStylePropertyFlags::MARGIN_RIGHT, + ); + let legacy = self.legacy_style_override.get_or_insert_with(Default::default); + legacy.flags |= 0x0008; + legacy.horizontal_cell_margin = Some(number); + self.override_flag = true; + } + "tbl_vertical_margin" if number >= 0.0 => { + let style = self.base_style.get_or_insert_with(Default::default); + style.margin_top = number; + style.margin_bottom = number; + style.property_flags.insert( + CellStylePropertyFlags::MARGIN_TOP | CellStylePropertyFlags::MARGIN_BOTTOM, + ); + let legacy = self.legacy_style_override.get_or_insert_with(Default::default); + legacy.flags |= 0x0010; + legacy.vertical_cell_margin = Some(number); + self.override_flag = true; + } + "tbl_cell_text_height" if number > 0.0 => { + if let Some((row, column)) = cell_position { + let state = self + .cell(row, column) + .map(|cell| cell.state) + .unwrap_or_default(); + if state.intersects( + CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY, + ) { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); + style.text_height = number; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::TEXT_HEIGHT, + ); + } + } + } + "tbl_cell_margin_left" + | "tbl_cell_margin_top" + | "tbl_cell_margin_right" + | "tbl_cell_margin_bottom" + if number >= 0.0 => + { + if let Some((row, column)) = cell_position { + let state = self + .cell(row, column) + .map(|cell| cell.state) + .unwrap_or_default(); + if state.intersects( + CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY, + ) { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); + let flag = match field { + "tbl_cell_margin_left" => { + style.margin_left = number; + CellStylePropertyFlags::MARGIN_LEFT + } + "tbl_cell_margin_top" => { + style.margin_top = number; + CellStylePropertyFlags::MARGIN_TOP + } + "tbl_cell_margin_right" => { + style.margin_right = number; + CellStylePropertyFlags::MARGIN_RIGHT + } + _ => { + style.margin_bottom = number; + CellStylePropertyFlags::MARGIN_BOTTOM + } + }; + style.property_flags.insert(flag); + } + } + } + "tbl_break_height" + if number >= 0.0 + && self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS) + && !self + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_HEIGHTS) => + { + if self.break_data.is_empty() { + self.break_data.push(acadrust::entities::table::TableBreakData { + position: acadrust::types::Vector3::ZERO, + height: number, + flags: 0, + }); + } else { + for data in &mut self.break_data { + data.height = number; + } + } + self.break_ranges.clear(); + } + "tbl_break_spacing" + if number >= 0.0 + && self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS) + && !self + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS) => + { + self.break_spacing = number; + self.break_ranges.clear(); + } + _ => {} } } } diff --git a/src/entities/text.rs b/src/entities/text.rs index 18a96fda..684b3612 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -43,7 +43,24 @@ pub(crate) fn sync_text_alignment_point(t: &mut Text) { (HA::Left, VA::Baseline) ); if needs_alignment_point { - if t.alignment_point.is_none() { + if matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) { + let point = t.alignment_point.unwrap_or(t.insertion_point); + let dx = point.x - t.insertion_point.x; + let dy = point.y - t.insertion_point.y; + if dx.hypot(dy) <= 1.0e-9 { + let span = t.height.max(1.0e-6) + * t.width_factor.abs().max(0.01) + * t.value.chars().count().max(1) as f64 + * 0.6; + t.alignment_point = Some(acadrust::types::Vector3::new( + t.insertion_point.x + t.rotation.cos() * span, + t.insertion_point.y + t.rotation.sin() * span, + t.insertion_point.z, + )); + } else { + t.alignment_point = Some(point); + } + } else if t.alignment_point.is_none() { t.alignment_point = Some(t.insertion_point); } } else { @@ -51,6 +68,21 @@ pub(crate) fn sync_text_alignment_point(t: &mut Text) { } } +fn two_point_span(t: &Text) -> Option<(f64, f64)> { + if !matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) { + return None; + } + let point = t.alignment_point?; + let dx = point.x - t.insertion_point.x; + let dy = point.y - t.insertion_point.y; + let distance = dx.hypot(dy); + (distance > 1.0e-9).then(|| (distance, dy.atan2(dx))) +} + +fn displayed_rotation(t: &Text) -> f64 { + two_point_span(t).map_or(t.rotation, |(_, angle)| angle) +} + /// Resolved placement of a TEXT run: the baseline-anchored run origin (WCS xy) /// plus every parameter needed to lay the glyphs out. Shared by `to_render` (the /// stroke path) and the SDF-quad text collector so both place text identically. @@ -98,8 +130,12 @@ pub(crate) fn acad_text_encode(value: &str) -> String { out } -fn to_render(t: &Text, document: &acadrust::CadDocument) -> RenderEntity { - let p = text_run_placement(t, document); +pub(crate) fn to_render_at_scale( + t: &Text, + document: &acadrust::CadDocument, + annotation_scale: f32, +) -> RenderEntity { + let p = text_run_placement_at_scale(t, document, annotation_scale); let snap_pt = glam::DVec3::new(p.wcs_insertion[0], p.wcs_insertion[1], p.wcs_insertion[2]); // Parse `%%` codes via acadrust, re-encoded for the stroke tessellator. let value = acad_text_encode(&p.value); @@ -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 { - let p = glam::DVec3::new( + let insertion = glam::DVec3::new( t.insertion_point.x, t.insertion_point.y, t.insertion_point.z, ); - vec![square_grip(0, p)] + let mut grips = vec![square_grip(0, insertion)]; + if let Some(point) = t.alignment_point { + grips.push(square_grip( + 1, + glam::DVec3::new(point.x, point.y, point.z), + )); + } + grips } fn properties(t: &Text, text_style_names: &[String]) -> Vec { @@ -263,15 +342,25 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec { // both points are live. let is_plain_left = matches!(t.horizontal_alignment, HA::Left) && matches!(t.vertical_alignment, VA::Baseline); - let pos_editable = is_plain_left || matches!(t.horizontal_alignment, HA::Aligned | HA::Fit); - let align_editable = !is_plain_left; - // The alignment point is meaningless (reset to the origin) for plain-Left text. + let is_aligned = matches!(t.horizontal_alignment, HA::Aligned); + let is_two_point = matches!(t.horizontal_alignment, HA::Aligned | HA::Fit); + // The visible Properties palette exposes Text alignment as calculated + // coordinates and Position as the editable placement point. For aligned + // and fit text Position targets the first endpoint; for every other + // non-left mode it targets the alignment anchor that actually places the + // glyph run. + let position_uses_alignment = !is_plain_left && !is_two_point; let ap = t.alignment_point.unwrap_or(t.insertion_point); - let (ax, ay, az) = if align_editable { + let (ax, ay, az) = if !is_plain_left { (ap.x, ap.y, ap.z) } else { (0.0, 0.0, 0.0) }; + let position = if position_uses_alignment { + ap + } else { + t.insertion_point + }; vec![ PropSection { title: t!("Text").into_owned(), @@ -331,22 +420,31 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec { t!("Height").as_ref(), "height", t.height, - crate::entities::common::style_fixed_height(&t.style).is_none(), + !is_aligned + && crate::entities::common::style_fixed_height(&t.style).is_none(), ), - edit_angle(t!("Rotation").as_ref(), "rotation", t.rotation.to_degrees()), + if is_two_point { + ro( + t!("Rotation").as_ref(), + "rotation", + crate::entities::common::format_angle(displayed_rotation(t)), + ) + } else { + edit_angle(t!("Rotation").as_ref(), "rotation", t.rotation.to_degrees()) + }, edit(t!("Width factor").as_ref(), "width_factor", t.width_factor), edit_angle(t!("Obliquing").as_ref(), "oblique_angle", t.oblique_angle.to_degrees()), - num_row(t!("Text alignment X").as_ref(), "align_x", ax, align_editable), - num_row(t!("Text alignment Y").as_ref(), "align_y", ay, align_editable), - num_row(t!("Text alignment Z").as_ref(), "align_z", az, align_editable), + num_row(t!("Text alignment X").as_ref(), "align_x", ax, false), + num_row(t!("Text alignment Y").as_ref(), "align_y", ay, false), + num_row(t!("Text alignment Z").as_ref(), "align_z", az, false), ], }, PropSection { title: t!("Geometry").into_owned(), props: vec![ - num_row(t!("Position X").as_ref(), "ins_x", t.insertion_point.x, pos_editable), - num_row(t!("Position Y").as_ref(), "ins_y", t.insertion_point.y, pos_editable), - num_row(t!("Position Z").as_ref(), "ins_z", t.insertion_point.z, pos_editable), + num_row(t!("Position X").as_ref(), "ins_x", position.x, true), + num_row(t!("Position Y").as_ref(), "ins_y", position.y, true), + num_row(t!("Position Z").as_ref(), "ins_z", position.z, true), ], }, PropSection { @@ -439,37 +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 { - 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(); } } diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 0ddc32aa..2e4b83ed 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -11,6 +11,7 @@ pub struct ResolvedTextStyle { pub oblique_angle: f32, pub is_backward: bool, pub is_upside_down: bool, + pub is_vertical: bool, } pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedTextStyle { @@ -76,6 +77,7 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0), is_backward: style.map(|s| s.is_backward()).unwrap_or(false), is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false), + is_vertical: style.map(|s| s.is_vertical).unwrap_or(false), } } @@ -1012,6 +1014,11 @@ pub struct MTextRenderOpts<'a> { pub v_anchor: MTextVAnchor, /// DXF code 44 — multiplier on the default 5/3-em baseline gap. pub line_spacing_factor: f32, + /// `true` fixes every baseline advance to the entity spacing. `false` + /// treats it as a minimum and expands for taller inline runs. + pub exact_line_spacing: bool, + /// User-defined text-box/column height. Zero means content-driven. + pub rectangle_height: f32, /// `true` when the entity is laid out top-to-bottom (DXF code 71 = 2). pub vertical_text: bool, /// When true, `layout_mtext` also fills `MTextLayout::glyph_boxes` with @@ -1025,10 +1032,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, } 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, 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, } - 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, }) diff --git a/src/entities/tolerance.rs b/src/entities/tolerance.rs index f293e59f..4848af6f 100644 --- a/src/entities/tolerance.rs +++ b/src/entities/tolerance.rs @@ -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 { +pub(crate) fn symbol_font_switch(inner: &str) -> Option { let rest = inner .strip_prefix("\\F") .or_else(|| inner.strip_prefix("\\f"))?; @@ -144,51 +146,6 @@ fn symbol_font_switch(inner: &str) -> Option { 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 { - // 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 = 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 { /// 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>, 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> = 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::new(); let mut cells: Vec = 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> = 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> { + 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| { + 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 { + fn geometry_properties(&self, text_style_names: &[String]) -> Vec { 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" diff --git a/src/io/mod.rs b/src/io/mod.rs index 4d4a6832..5bbeb094 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -751,16 +751,17 @@ async fn load_web_bytes( Ok((name.to_string(), path, doc, caches)) } -/// Parse a CAD document from in-memory bytes, choosing the format from -/// `name`'s extension. Used by the web build, where files arrive as bytes from -/// a browser file picker (no filesystem path). Shares the post-load fixups with -/// [`load_file`]; raster-image path resolution is skipped (there is no sibling -/// directory to search on the web). +/// Parse a drawing from bytes using its name or recovery-file signature. #[cfg(not(target_arch = "wasm32"))] pub fn load_bytes(name: &str, bytes: Vec) -> Result { use std::io::Cursor; let ext = name.rsplit('.').next().unwrap_or_default().to_lowercase(); - match ext.as_str() { + let format = if matches!(ext.as_str(), "bak" | "sv$") { + sniff_dwg_or_dxf_bytes(&bytes) + } else { + ext.as_str() + }; + match format { "dwg" => { let mut doc = DwgReader::from_stream(Cursor::new(bytes)) .read() @@ -793,10 +794,14 @@ fn sniff_dwg_or_dxf(path: &Path) -> String { if let Ok(mut f) = std::fs::File::open(path) { let _ = f.read(&mut buf); } - if buf.starts_with(b"AC10") { - "dwg".to_string() + sniff_dwg_or_dxf_bytes(&buf).to_string() +} + +fn sniff_dwg_or_dxf_bytes(bytes: &[u8]) -> &'static str { + if bytes.starts_with(b"AC10") { + "dwg" } else { - "dxf".to_string() + "dxf" } } @@ -1401,7 +1406,7 @@ fn validate_save_extension(path: &Path) -> Result<(), SaveFailure> { .extension() .map(|value| value.to_string_lossy().to_lowercase()) .unwrap_or_default(); - if matches!(extension.as_str(), "dwg" | "dxf") { + if matches!(extension.as_str(), "dwg" | "dxf" | "sv$") { return Ok(()); } diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 8200a893..bd32fd03 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -25,6 +25,7 @@ use std::io::Write; use std::path::Path; #[derive(Clone, Debug)] +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub struct PlotWire { pub wire: WireModel, pub draw_depth: f32, @@ -120,6 +121,7 @@ pub struct PdfPageInput { pub scale: f32, pub clip: Option<(f32, f32, f32, f32)>, pub options: PdfPlotOptions, + pub plot_style: Option, } impl Default for PdfPlotOptions { @@ -270,7 +272,7 @@ fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) page.rotation_deg, page.scale, page.clip, - plot_style, + page.plot_style.as_ref().or(plot_style), page.options, ); } @@ -440,6 +442,8 @@ fn append_pdf_page( let mut last_color: Option<[f32; 3]> = None; let mut last_lw: Option = None; + let mut last_cap = Some(LineCapStyle::Round); + let mut last_join = Some(LineJoinStyle::Round); // Current PDF dash array (empty = solid). Tracked so the dash op is only // re-emitted when it actually changes between wires. let mut last_dash: Option> = None; @@ -453,7 +457,9 @@ fn append_pdf_page( ox, oy, plot_style, + scale, options, + normal_blend.as_ref(), ); last_color = None; last_lw = None; @@ -506,6 +512,8 @@ fn append_pdf_page( let mut lw_override: Option = None; let mut screening = 1.0; let mut color_overridden = false; + let mut cap = None; + let mut join = None; if let Some(ctb) = plot_style { if wire.aci > 0 { if let Some([cr, cg, cb]) = ctb.resolve_color(wire.aci) { @@ -518,6 +526,20 @@ fn append_pdf_page( .resolve_lineweight(wire.aci) .map(|mm| (mm * MM_TO_PT).max(0.1)); screening = ctb.resolve_screening(wire.aci); + if let Some(entry) = ctb.aci_entries.get(wire.aci as usize) { + cap = match entry.end_style { + 0 => Some(LineCapStyle::Butt), + 1 | 3 => Some(LineCapStyle::ProjectingSquare), + 2 => Some(LineCapStyle::Round), + _ => None, + }; + join = match entry.join_style { + 0 => Some(LineJoinStyle::Miter), + 1 | 3 => Some(LineJoinStyle::Bevel), + 2 => Some(LineJoinStyle::Round), + _ => None, + }; + } } } // Near-white and near-yellow (viewport active border) → dark grey for print @@ -539,6 +561,17 @@ fn append_pdf_page( } [r, g, b] = plotted_color([r, g, b], a, screening, options); + let cap = cap.unwrap_or(LineCapStyle::Round); + if last_cap != Some(cap) { + ops.push(Op::SetLineCapStyle { cap }); + last_cap = Some(cap); + } + let join = join.unwrap_or(LineJoinStyle::Round); + if last_join != Some(join) { + ops.push(Op::SetLineJoinStyle { join }); + last_join = Some(join); + } + if last_color .map(|c| (c[0] - r).abs() > 0.01 || (c[1] - g).abs() > 0.01 || (c[2] - b).abs() > 0.01) .unwrap_or(true) @@ -576,13 +609,11 @@ fn append_pdf_page( let lw_pt = if wire.world_width > 0.0 { wire.world_width * MM_TO_PT } else { - let physical = lw_override.unwrap_or_else(|| { - if options.object_lineweights { - (wire.line_weight_px * LW_PX_TO_PT).max(0.1) - } else { - 0.1 - } - }); + let physical = if options.object_lineweights { + lw_override.unwrap_or_else(|| (wire.line_weight_px * LW_PX_TO_PT).max(0.1)) + } else { + 0.1 + }; physical / pen_divisor }; if last_lw.map(|l| (l - lw_pt).abs() > 0.01).unwrap_or(true) { @@ -593,6 +624,43 @@ fn append_pdf_page( // Linetype dash pattern. Without this every wire exported as a solid // line regardless of its linetype (dashed / centre / dash-dot). (#155) let dash_arr = dash_array_from_pattern(wire.pattern_length, &wire.pattern, MM_TO_PT); + let stationed = + !dash_arr.is_empty() && wire.pattern_stations.len() > wire.points.len(); + if stationed { + if last_dash.as_ref().is_none_or(|dash| !dash.is_empty()) { + ops.push(Op::SetLineDashPattern { + dash: LineDashPattern::default(), + }); + last_dash = Some(Vec::new()); + } + for index in 0..wire.points.len().saturating_sub(1) { + if !wire.points[index][0].is_finite() + || !wire.points[index + 1][0].is_finite() + { + continue; + } + let start = wire.point_world(index, paper_h as f64 / scale.max(1e-6) as f64); + let end = wire.point_world(index + 1, paper_h as f64 / scale.max(1e-6) as f64); + for [from, to] in visible_station_ranges( + wire.pattern_stations[index], + wire.pattern_stations[index + 1], + wire.pattern_length, + &wire.pattern, + ) { + let point = |t: f32| { + LinePoint { + p: Point::new( + Mm((start.x + (end.x - start.x) * t as f64 + ox) as f32), + Mm((start.y + (end.y - start.y) * t as f64 + oy) as f32), + ), + bezier: false, + } + }; + flush_line(&mut ops, &[point(from), point(to)], None); + } + } + continue; + } if last_dash.as_deref() != Some(dash_arr.as_slice()) { let dash = if dash_arr.is_empty() { LineDashPattern::default() @@ -600,7 +668,7 @@ fn append_pdf_page( LineDashPattern::from_array(&dash_arr, 0) }; ops.push(Op::SetLineDashPattern { dash }); - last_dash = Some(dash_arr); + last_dash = Some(dash_arr.clone()); } // Emit segments (NaN = pen-up). Points are the "high" half of a @@ -670,6 +738,76 @@ fn dash_array_from_pattern(pattern_length: f32, pattern: &[f32; 8], mm_to_pt: f3 .collect() } +#[cfg(not(target_arch = "wasm32"))] +fn visible_station_ranges( + start: f32, + end: f32, + pattern_length: f32, + pattern: &[f32; 8], +) -> Vec<[f32; 2]> { + let count = pattern.iter().rposition(|value| *value != 0.0).map_or(0, |i| i + 1); + if count == 0 || pattern_length <= 1e-6 { + return vec![[0.0, 1.0]]; + } + let dot = 1.0 / MM_TO_PT; + let mut elements: Vec<(f32, bool)> = pattern[..count] + .iter() + .map(|value| (if *value == 0.0 { dot } else { value.abs() }, *value >= 0.0)) + .collect(); + let total: f32 = elements.iter().map(|(length, _)| *length).sum(); + if total <= 1e-6 { + return vec![[0.0, 1.0]]; + } + let factor = pattern_length / total; + for (length, _) in &mut elements { + *length *= factor; + } + let delta = end - start; + let state = |station: f32, forward: bool| { + let mut phase = station.rem_euclid(pattern_length); + if !forward && phase <= 1e-6 { + phase = pattern_length; + } + let mut offset = 0.0; + if forward { + for &(length, drawn) in &elements { + let end = offset + length; + if phase < end - 1e-6 { + return (drawn, end - phase); + } + offset = end; + } + (elements[0].1, elements[0].0) + } else { + offset = pattern_length; + for &(length, drawn) in elements.iter().rev() { + offset -= length; + if phase > offset + 1e-6 { + return (drawn, phase - offset); + } + } + let last = elements[elements.len() - 1]; + (last.1, last.0) + } + }; + if delta.abs() <= 1e-6 { + return state(start, true).0.then_some([0.0, 1.0]).into_iter().collect(); + } + + let mut ranges = Vec::new(); + let mut t = 0.0; + while t < 1.0 - 1e-6 { + let station = start + delta * t; + let (drawn, remaining) = state(station, delta > 0.0); + let next = (t + remaining / delta.abs()).clamp(t + 1e-6, 1.0); + if drawn { + ranges.push([t, next]); + } + t = next; + } + ranges +} + #[cfg(not(target_arch = "wasm32"))] fn flush_line(ops: &mut Vec, pts: &[LinePoint], dot_radius: Option) { if pts.len() < 2 { @@ -745,12 +883,71 @@ fn emit_wire_fills( ox: f64, oy: f64, plot_style: Option<&PlotStyleTable>, + scale: f32, options: PdfPlotOptions, + normal_blend: Option<&ExtendedGraphicsStateId>, ) { for wire in wires { if wire.fill_tris.is_empty() { continue; } + let styled_pattern = plot_style.and_then(|table| { + (wire.aci > 0) + .then(|| table.aci_entries.get(wire.aci as usize)) + .flatten() + .and_then(|entry| { + (65..=72) + .contains(&entry.fill_style) + .then(|| { + crate::scene::model::hatch_model::plot_style_fill_pattern( + entry.fill_style, + ) + }) + .flatten() + }) + }); + if let Some(pattern) = styled_pattern { + for (triangle_index, triangle) in wire.fill_tris.chunks_exact(3).enumerate() { + let mut boundary = Vec::with_capacity(4); + for (point_index, point) in triangle.iter().enumerate() { + let index = triangle_index * 3 + point_index; + let low = wire.fill_tris_low.get(index).copied().unwrap_or([0.0; 3]); + boundary.push([point[0] + low[0], point[1] + low[1]]); + } + boundary.push(boundary[0]); + let hatch = HatchModel { + render_instance: wire.render_instance.clone(), + world_origin: [0.0, 0.0], + boundary: std::sync::Arc::new(boundary), + boundary_wcs: None, + fill_plane: None, + fill_plane_boundary: None, + boundary_exterior: None, + boundary_sources: None, + boundary_paths: None, + style: acadrust::entities::HatchStyleType::Normal, + pattern: pattern.clone(), + name: "PLOTSTYLE".to_string(), + color: wire.color, + aci: wire.aci, + line_weight_px: wire.line_weight_px, + angle_offset: 0.0, + scale: 1.0 / scale.max(1.0e-6), + draw_depth: wire.depth_override.unwrap_or(0.0), + }; + emit_hatch( + ops, + &hatch, + ox, + oy, + plot_style, + scale, + options, + normal_blend, + ); + } + continue; + } let [mut r, mut g, mut b, a] = wire.color; if a < 0.01 { continue; @@ -857,6 +1054,26 @@ fn emit_hatch( if hatch.boundary.is_empty() { return; } + let mut styled_hatch = None; + if let Some(table) = plot_style { + if hatch.aci > 0 && matches!(hatch.pattern, HatchPattern::Solid) { + if let Some(pattern) = table + .aci_entries + .get(hatch.aci as usize) + .and_then(|entry| { + crate::scene::model::hatch_model::plot_style_fill_pattern( + entry.fill_style, + ) + }) + { + let mut model = hatch.clone(); + model.pattern = pattern; + model.scale = 1.0 / scale.max(1.0e-6); + styled_hatch = Some(model); + } + } + } + let hatch = styled_hatch.as_ref().unwrap_or(hatch); let [mut r, mut g, mut b, a] = hatch.color; if a < 0.01 { return; @@ -975,13 +1192,11 @@ fn emit_hatch( // Pattern hatches: rasterise the family lines clipped to the boundary // and emit each as a stroked line. Skips the polygon outline entirely. if matches!(hatch.pattern, HatchPattern::Pattern(_)) { - let physical = lw_override.unwrap_or_else(|| { - if options.object_lineweights { - (hatch.line_weight_px * LW_PX_TO_PT).max(0.1) - } else { - 0.1 - } - }); + let physical = if options.object_lineweights { + lw_override.unwrap_or_else(|| (hatch.line_weight_px * LW_PX_TO_PT).max(0.1)) + } else { + 0.1 + }; let divisor = if options.scale_lineweights { 1.0 } else { @@ -1141,16 +1356,16 @@ fn emit_text( if let Some(ctb) = plot_style { if wire.aci > 0 { ctb_color = ctb.resolve_color(wire.aci); - lw_override = ctb - .resolve_lineweight(wire.aci) - .map(|mm| { + lw_override = options.object_lineweights.then(|| { + ctb.resolve_lineweight(wire.aci).map(|mm| { let divisor = if options.scale_lineweights { 1.0 } else { scale.max(1e-6) }; (mm * MM_TO_PT).max(0.1) / divisor - }); + }) + }).flatten(); screening = ctb.resolve_screening(wire.aci); } } @@ -1292,12 +1507,15 @@ mod tests { #[test] fn clip_and_scale_emit_pdf_bytes() { - let w = WireModel::solid( - "test".into(), - vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]], - WireModel::WHITE, - false, - ); + let w = PlotWire { + wire: WireModel::solid( + "test".into(), + vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]], + WireModel::WHITE, + false, + ), + draw_depth: 0.0, + }; let bytes = build_pdf( &[w], &[], @@ -1319,7 +1537,7 @@ mod tests { // Build a WireModel carrying the SDF glyph quads for `text` in the embedded // "txt" stroke font, laid out into the process-wide atlas emit_text reads. - fn text_wire(text: &str, origin: [f64; 3]) -> WireModel { + fn text_wire(text: &str, origin: [f64; 3]) -> PlotWire { use crate::scene::pipeline::text_gpu::push_glyph_vertices; use crate::scene::text::{glyph_quads::layout_glyph_quads, sdf_atlas}; let quads = { @@ -1329,9 +1547,12 @@ mod tests { assert!(!quads.is_empty(), "stroke glyphs laid out for {text:?}"); let mut verts = Vec::new(); push_glyph_vertices(&mut verts, &quads, origin, 1.0, [1.0, 0.0, 0.0, 1.0], 0.0); - WireModel { - text_verts: verts, - ..WireModel::solid("t".into(), Vec::new(), WireModel::WHITE, false) + PlotWire { + wire: WireModel { + text_verts: verts, + ..WireModel::solid("t".into(), Vec::new(), WireModel::WHITE, false) + }, + draw_depth: 0.0, } } @@ -1341,7 +1562,7 @@ mod tests { fn text_grows_the_pdf_vs_no_text() { let wire = text_wire("HELLO", [20.0, 20.0, 0.0]); let mut blank = wire.clone(); - blank.text_verts.clear(); + blank.wire.text_verts.clear(); let with_text = build_pdf( &[wire], diff --git a/src/io/plot_style.rs b/src/io/plot_style.rs index a1e01360..f44eb88c 100644 --- a/src/io/plot_style.rs +++ b/src/io/plot_style.rs @@ -1,7 +1,7 @@ //! Plot Style Table — CTB (color-based) and STB (named) file support. //! //! CTB files map indexed drawing colors (ACI, 1-255) to pen properties: -//! RGB color override, lineweight, and screeing percentage. +//! RGB color override, lineweight, and screening percentage. //! //! File format: a fixed 60-byte header followed by zlib-compressed text. //! @@ -17,6 +17,21 @@ use std::path::PathBuf; pub const DEFAULT_PLOT_STYLE: &str = "ocad.ctb"; pub const MONOCHROME_PLOT_STYLE: &str = "monochrome.ctb"; +pub const GRAYSCALE_PLOT_STYLE: &str = "Grayscale.ctb"; +pub const FILL_PATTERNS_PLOT_STYLE: &str = "Fill Patterns.ctb"; +pub const SCREENING_100_PLOT_STYLE: &str = "Screening 100%.ctb"; +pub const SCREENING_75_PLOT_STYLE: &str = "Screening 75%.ctb"; +pub const SCREENING_50_PLOT_STYLE: &str = "Screening 50%.ctb"; +pub const SCREENING_25_PLOT_STYLE: &str = "Screening 25%.ctb"; + +const STANDARD_PLOT_STYLES: &[&str] = &[ + GRAYSCALE_PLOT_STYLE, + FILL_PATTERNS_PLOT_STYLE, + SCREENING_100_PLOT_STYLE, + SCREENING_75_PLOT_STYLE, + SCREENING_50_PLOT_STYLE, + SCREENING_25_PLOT_STYLE, +]; #[cfg(not(target_arch = "wasm32"))] pub fn plot_styles_dir() -> Result { @@ -52,10 +67,14 @@ pub fn available_ctb_names() -> Vec { #[cfg(not(target_arch = "wasm32"))] { let Ok(dir) = ensure_plot_styles_dir() else { - return vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()]; + let mut names = vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()]; + names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string())); + return names; }; let Ok(entries) = std::fs::read_dir(dir) else { - return vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()]; + let mut names = vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()]; + names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string())); + return names; }; let mut names: Vec = entries .filter_map(Result::ok) @@ -67,13 +86,16 @@ pub fn available_ctb_names() -> Vec { .then(|| entry.file_name().to_string_lossy().into_owned()) }) .collect(); + names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string())); names.sort_by_key(|name| name.to_ascii_lowercase()); names.dedup_by(|left, right| left.eq_ignore_ascii_case(right)); names } #[cfg(target_arch = "wasm32")] { - vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()] + let mut names = vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()]; + names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string())); + names } } @@ -199,10 +221,44 @@ impl PlotStyleTable { MONOCHROME_PLOT_STYLE, include_bytes!("../../assets/plotstyles/monochrome.ctb"), ), + "grayscale.ctb" => { + let mut table = Self::identity(GRAYSCALE_PLOT_STYLE); + for aci in 1..=255u8 { + if let Some((r, g, b)) = acadrust::types::aci_to_rgb(aci) { + let gray = if aci == 7 { + 0 + } else { + (0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32) + .round() as u8 + }; + table.aci_entries[aci as usize].color = Some([gray, gray, gray]); + } + } + Ok(table) + } + "fill patterns.ctb" => { + let mut table = Self::identity(FILL_PATTERNS_PLOT_STYLE); + for aci in 1..=9usize { + table.aci_entries[aci].fill_style = 63 + aci as u8; + } + Ok(table) + } + "screening 100%.ctb" => Ok(Self::screening(SCREENING_100_PLOT_STYLE, 100)), + "screening 75%.ctb" => Ok(Self::screening(SCREENING_75_PLOT_STYLE, 75)), + "screening 50%.ctb" => Ok(Self::screening(SCREENING_50_PLOT_STYLE, 50)), + "screening 25%.ctb" => Ok(Self::screening(SCREENING_25_PLOT_STYLE, 25)), _ => Err(format!("Unknown built-in plot style: {name}")), } } + fn screening(name: &str, percent: u8) -> Self { + let mut table = Self::identity(name); + for entry in table.aci_entries.iter_mut().skip(1) { + entry.screening = percent; + } + table + } + /// Load one CTB by file name from the per-user plot styles folder. pub fn load_named(name: &str) -> Result { let path = Path::new(name); @@ -227,9 +283,11 @@ impl PlotStyleTable { .to_string_lossy() .eq_ignore_ascii_case(name) }) - .map(|entry| entry.path()) - .ok_or_else(|| format!("Plot style not found: {name}"))?; - return Self::load(&matched); + .map(|entry| entry.path()); + return match matched { + Some(path) => Self::load(&path), + None => Self::builtin(name), + }; } #[cfg(target_arch = "wasm32")] @@ -248,9 +306,23 @@ impl PlotStyleTable { /// Returns None if no override (use object color). pub fn resolve_color(&self, aci: u8) -> Option<[f32; 3]> { let entry = self.aci_entries.get(aci as usize)?; - entry + let mut color = entry .color - .map(|[r, g, b]| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]) + .map(|[r, g, b]| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]); + if entry.color_policy & 2 != 0 { + let rgb = color.or_else(|| { + if aci == 7 { + Some([0.0; 3]) + } else { + acadrust::types::aci_to_rgb(aci).map(|(r, g, b)| { + [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0] + }) + } + })?; + let gray = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; + color = Some([gray; 3]); + } + color } /// Resolve the effective lineweight in mm for the given ACI index. @@ -260,7 +332,13 @@ impl PlotStyleTable { if matches!(entry.lineweight, 0 | 255) { None } else { - self.lineweights.get(entry.lineweight as usize).copied() + self.lineweights.get(entry.lineweight as usize).copied().map(|weight| { + if self.apply_factor { + weight * self.scale_factor.max(0.0) + } else { + weight + } + }) } } diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 61084e33..7e312810 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -13,6 +13,27 @@ use crate::io::plot_style::PlotStyleTable; use crate::scene::model::hatch_model::HatchModel; use crate::io::pdf_export::PlotWire; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn temp_pdf_path(kind: &str) -> std::path::PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!( + "open_cad_studio_{kind}_{}_{stamp}_{id}.pdf", + std::process::id() + )) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn temp_pdf_path(kind: &str) -> std::path::PathBuf { + std::path::PathBuf::from(format!("{kind}.pdf")) +} + /// Extra options for a print job. On CUPS (Linux/macOS) these map to `lp` /// flags / `-o` options. On Windows the generated PDF already carries render /// options. Windows queues repeated jobs when more than one copy is requested; @@ -166,7 +187,7 @@ pub async fn print_wires_with( plot_style: Option, opts: PrintOptions, ) -> Result { - let tmp_path = std::env::temp_dir().join("open_cad_studio_print.pdf"); + let tmp_path = temp_pdf_path("print"); pdf_export::export_pdf( &wires, &hatches, @@ -228,10 +249,15 @@ fn dispatch_to_printer_opts( ) -> Result { #[cfg(target_os = "windows")] { - // Target a named printer via the "printto" verb; fall back to the - // default-printer "print" verb. use std::ffi::OsStr; use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::{GetLastError, ERROR_NO_ASSOCIATION}; + use windows_sys::Win32::UI::Shell::{ + ShellExecuteExW, SHELLEXECUTEINFOW, SEE_MASK_FLAG_NO_UI, SEE_MASK_NOASYNC, + SE_ERR_NOASSOC, + }; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE; + let wide = |s: &str| -> Vec { OsStr::new(s).encode_wide().chain(Some(0)).collect() }; let path_wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); let (verb, params, label) = match opts.printer.as_deref() { @@ -240,18 +266,28 @@ fn dispatch_to_printer_opts( }; let params_ptr = params.as_ref().map(|v| v.as_ptr()).unwrap_or(std::ptr::null()); for _ in 0..opts.copies.max(1) { - let result = unsafe { - windows_sys::Win32::UI::Shell::ShellExecuteW( - std::ptr::null_mut(), - verb.as_ptr(), - path_wide.as_ptr(), - params_ptr, - std::ptr::null(), - windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE, - ) as usize + let mut info = SHELLEXECUTEINFOW { + cbSize: std::mem::size_of::() as u32, + fMask: SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC, + lpVerb: verb.as_ptr(), + lpFile: path_wide.as_ptr(), + lpParameters: params_ptr, + nShow: SW_HIDE, + ..Default::default() }; - if result <= 32 { - return Err(format!("ShellExecute failed (code {result})")); + if unsafe { ShellExecuteExW(&mut info) } == 0 { + let shell_code = info.hInstApp as usize; + let code = if (1..=32).contains(&shell_code) { + shell_code as u32 + } else { + unsafe { GetLastError() } + }; + if code == SE_ERR_NOASSOC || code == ERROR_NO_ASSOCIATION { + return Err( + "Windows has no PDF application registered with Print support.".into(), + ); + } + return Err(format!("Windows print dispatch failed (code {code})")); } } Ok(label) diff --git a/src/io/thumbnail.rs b/src/io/thumbnail.rs index 8b485053..28272b40 100644 --- a/src/io/thumbnail.rs +++ b/src/io/thumbnail.rs @@ -1,9 +1,7 @@ //! DWG preview thumbnails. //! -//! - [`from_snapshot`] rasterizes the drawing exactly as it is framed on screen — -//! the live camera's pan / zoom / rotation, only the currently visible region, -//! not the whole extent — into a small [`acadrust::Preview`] embedded on save -//! so OCS drawings show a thumbnail in file browsers and other CAD apps. +//! - [`from_screenshot`] crops the visible drawing area into a small +//! [`acadrust::Preview`] embedded on save. //! - [`read_handle`] / [`extract_to_png`] read a DWG's *embedded* preview back //! for the Start page and the OS file-manager thumbnailer. Extraction lives in //! the shared [`dwg_thumbnailer`] core crate (also used by the Windows/macOS @@ -11,67 +9,55 @@ use acadrust::{Preview, PreviewFormat}; use iced::Rectangle; -use image::{ImageFormat, Rgb, RgbImage}; +use image::{ImageFormat, RgbImage}; use std::io::Cursor; -use crate::scene::WireModel; -use crate::scene::view::camera::Camera; +/// Build a preview from the visible drawing area of an Iced window screenshot. +pub fn from_screenshot( + screenshot: &iced::window::Screenshot, + bounds: Rectangle, + png: bool, +) -> Option { + let scale = screenshot.scale_factor; + if !scale.is_finite() || scale <= 0.0 || bounds.width <= 0.0 || bounds.height <= 0.0 { + return None; + } + + let width = screenshot.size.width as f32; + let height = screenshot.size.height as f32; + let left = (bounds.x * scale).floor().clamp(0.0, width) as u32; + let top = (bounds.y * scale).floor().clamp(0.0, height) as u32; + let right = ((bounds.x + bounds.width) * scale) + .ceil() + .clamp(0.0, width) as u32; + let bottom = ((bounds.y + bounds.height) * scale) + .ceil() + .clamp(0.0, height) as u32; + if right <= left || bottom <= top { + return None; + } + + let cropped = screenshot + .crop(Rectangle { + x: left, + y: top, + width: right - left, + height: bottom - top, + }) + .ok()?; + let rgba = image::RgbaImage::from_raw( + cropped.size.width, + cropped.size.height, + cropped.rgba.to_vec(), + )?; + let (cw, ch) = canvas_dims(cropped.size.width as f64 / cropped.size.height as f64); + let resized = image::imageops::resize(&rgba, cw, ch, image::imageops::FilterType::Triangle); + encode(image::DynamicImage::ImageRgba8(resized).into_rgb8(), png) +} /// Longest edge of the generated thumbnail, in pixels. const MAX_DIM: u32 = 256; -/// Build a preview matching what is currently on screen: the drawing's wires -/// projected through the live camera into a `viewport`-aspect canvas, so the -/// thumbnail is the visible framing (pan / zoom / rotation, only the on-screen -/// region), not the whole extent. `viewport` is the model pane's pixel size. -/// `None` when the drawing is empty (clears any stale preview). -/// -/// `png` picks the encoding: a line drawing on a flat background is almost all -/// one colour, so a **PNG** collapses to a few KB where the uncompressed -/// **BMP/DIB** stays ~180 KB at 256². PNG previews are only valid from R2013 -/// (AC1027) on, so the caller passes `false` for older targets → BMP/DIB. -/// -/// Native background saves retain -/// the resident wire `Arc` and a small camera copy, then run the point scan and -/// image encoding on the save worker instead of blocking the UI thread. -pub fn from_snapshot( - wires: &[WireModel], - camera: &Camera, - bg_color: [f32; 4], - png: bool, - viewport: (f32, f32), -) -> Option { - if wires.is_empty() { - return None; - } - let (vw, vh) = viewport; - if !(vw > 0.0 && vh > 0.0) { - return None; - } - - // Canvas keeps the viewport's aspect so the framing is undistorted; longest - // edge = MAX_DIM. Projecting with the canvas rectangle as the camera bounds - // makes `project` return pixel coordinates already in canvas space. - let (cw, ch) = canvas_dims((vw / vh) as f64); - let bounds = Rectangle { - x: 0.0, - y: 0.0, - width: cw as f32, - height: ch as f32, - }; - #[cfg(not(target_arch = "wasm32"))] - { - rasterize_snapshot_parallel(wires, camera, bounds, cw, ch, bg_color, png) - } - #[cfg(target_arch = "wasm32")] - { - rasterize(wires, cw, ch, bg_color, png, |x, y, z| { - camera.project(glam::DVec3::new(x, y, z), bounds) - .map(|s| (s.x.round() as i32, s.y.round() as i32)) - }) - } -} - /// Canvas dimensions for an aspect ratio, longest edge = [`MAX_DIM`]. fn canvas_dims(aspect: f64) -> (u32, u32) { if aspect >= 1.0 { @@ -81,184 +67,7 @@ fn canvas_dims(aspect: f64) -> (u32, u32) { } } -#[cfg(not(target_arch = "wasm32"))] -fn rasterize_snapshot_parallel( - wires: &[WireModel], - camera: &Camera, - bounds: Rectangle, - cw: u32, - ch: u32, - bg: [f32; 4], - png: bool, -) -> Option { - use crate::par::prelude::*; - - let total_segments: usize = wires - .iter() - .map(|wire| wire.points.len().saturating_sub(1)) - .sum(); - if total_segments < 100_000 { - return rasterize(wires, cw, ch, bg, png, |x, y, z| { - camera.project(glam::DVec3::new(x, y, z), bounds) - .map(|point| (point.x.round() as i32, point.y.round() as i32)) - }); - } - - let task_count = (rayon::current_num_threads().max(1) * 2).min(total_segments); - let target_work = total_segments.div_ceil(task_count).max(1); - let mut tasks: Vec> = Vec::with_capacity(task_count); - let mut task = Vec::new(); - let mut task_work = 0usize; - for (wire_index, wire) in wires.iter().enumerate() { - let segment_count = wire.points.len().saturating_sub(1); - let mut start = 0usize; - while start < segment_count { - let take = (target_work - task_work).min(segment_count - start); - task.push((wire_index, start, start + take)); - task_work += take; - start += take; - if task_work == target_work { - tasks.push(std::mem::take(&mut task)); - task_work = 0; - } - } - } - if !task.is_empty() { - tasks.push(task); - } - - let layers: Vec> = tasks - .par_iter() - .map(|task| { - let mut pixels = vec![u32::MAX; (cw * ch) as usize]; - for &(wire_index, start, end) in task { - let wire = &wires[wire_index]; - let color = to_rgb(wire.color); - let packed = - color[0] as u32 | (color[1] as u32) << 8 | (color[2] as u32) << 16; - let mut previous = projected_wire_point(wire, start, camera, bounds); - for index in start + 1..=end { - let current = projected_wire_point(wire, index, camera, bounds); - if let (Some(a), Some(b)) = (previous, current) { - draw_line_layer(&mut pixels, cw, ch, a, b, packed); - } - previous = current; - } - } - pixels - }) - .collect(); - - let mut image = RgbImage::from_pixel(cw, ch, Rgb(to_rgb(bg))); - for layer in layers { - for (pixel, packed) in image.pixels_mut().zip(layer) { - if packed != u32::MAX { - *pixel = Rgb([ - packed as u8, - (packed >> 8) as u8, - (packed >> 16) as u8, - ]); - } - } - } - encode(image, png) -} - -#[cfg(not(target_arch = "wasm32"))] -fn projected_wire_point( - wire: &WireModel, - index: usize, - camera: &Camera, - bounds: Rectangle, -) -> Option<(i32, i32)> { - let point = wire.points.get(index)?; - if !point[0].is_finite() || !point[1].is_finite() { - return None; - } - let (x, y, z) = abs_xyz(wire, index, point); - camera - .project(glam::DVec3::new(x, y, z), bounds) - .map(|screen| (screen.x.round() as i32, screen.y.round() as i32)) -} - -#[cfg(not(target_arch = "wasm32"))] -fn draw_line_layer( - pixels: &mut [u32], - width: u32, - height: u32, - (x0, y0): (i32, i32), - (x1, y1): (i32, i32), - color: u32, -) { - let (width, height) = (width as i32, height as i32); - let Some(((mut x0, mut y0), (x1, y1))) = - clip_line_to_rect((x0, y0), (x1, y1), width, height) - else { - return; - }; - let dx = (x1 - x0).abs(); - let dy = -(y1 - y0).abs(); - let sx = if x0 < x1 { 1 } else { -1 }; - let sy = if y0 < y1 { 1 } else { -1 }; - let mut error = dx + dy; - loop { - if x0 >= 0 && x0 < width && y0 >= 0 && y0 < height { - pixels[y0 as usize * width as usize + x0 as usize] = color; - } - if x0 == x1 && y0 == y1 { - break; - } - let twice = 2 * error; - if twice >= dy { - error += dy; - x0 += sx; - } - if twice <= dx { - error += dx; - y0 += sy; - } - } -} - -/// Rasterize `wires` onto a `bg`-filled `cw`×`ch` canvas, placing each vertex -/// with `project` (world XYZ → canvas pixel, `None` = not projectable), and -/// encode the result. A `None` from `project` breaks the polyline run, as does a -/// NaN separator, so off-screen / clipped segments simply don't draw. -fn rasterize( - wires: &[WireModel], - cw: u32, - ch: u32, - bg: [f32; 4], - png: bool, - project: impl Fn(f64, f64, f64) -> Option<(i32, i32)>, -) -> Option { - let mut img = RgbImage::from_pixel(cw, ch, Rgb(to_rgb(bg))); - for w in wires { - let col = Rgb(to_rgb(w.color)); - let mut prev: Option<(i32, i32)> = None; - for (i, p) in w.points.iter().enumerate() { - if !p[0].is_finite() || !p[1].is_finite() { - prev = None; // NaN separator breaks the run - continue; - } - let (x, y, z) = abs_xyz(w, i, p); - let cur = project(x, y, z); - if let (Some(a), Some(b)) = (prev, cur) { - draw_line(&mut img, a, b, col); - } - prev = cur; - } - } - encode(img, png) -} - -/// Encode the canvas. PNG for R2013+ targets (few KB); else a BMP → DIB (no -/// 14-byte BITMAPFILEHEADER, which the DWG preview container doesn't carry). -/// The BMP is an 8-bit **RLE8**-compressed DIB — a line drawing on a flat -/// background is a few distinct colours with long single-colour runs, so it -/// collapses from the ~180 KB of a 24-bit DIB to a handful of KB. A view with -/// more than 256 distinct colours (rare) can't be palettised, so it falls back -/// to the 24-bit uncompressed DIB. +/// Encode PNG for R2013+; older targets receive a BMP/DIB. fn encode(img: RgbImage, png: bool) -> Option { if png { let mut buf = Cursor::new(Vec::new()); @@ -347,25 +156,6 @@ fn bmp24_dib(img: &RgbImage) -> Option> { (bmp.len() > 14).then(|| bmp[14..].to_vec()) } -/// Absolute world XYZ of vertex `i`, reconstructing the double-single residual. -#[inline] -fn abs_xyz(w: &WireModel, i: usize, p: &[f32; 3]) -> (f64, f64, f64) { - let (lx, ly, lz) = w - .points_low - .get(i) - .map_or((0.0, 0.0, 0.0), |l| (l[0] as f64, l[1] as f64, l[2] as f64)); - (p[0] as f64 + lx, p[1] as f64 + ly, p[2] as f64 + lz) -} - -#[inline] -fn to_rgb(c: [f32; 4]) -> [u8; 3] { - [ - (c[0].clamp(0.0, 1.0) * 255.0).round() as u8, - (c[1].clamp(0.0, 1.0) * 255.0).round() as u8, - (c[2].clamp(0.0, 1.0) * 255.0).round() as u8, - ] -} - /// Read a DWG's embedded preview and write it as a PNG at `output`, scaled so /// its longest edge is at most `size`. Returns `false` on any failure (no /// preview, undecodable, write error) so the OS thumbnailer falls back to a @@ -399,14 +189,6 @@ pub fn read_handle(path: &std::path::Path) -> Option WireModel { - WireModel { - points: pts.iter().map(|&[x, y]| [x, y, 0.0]).collect(), - color, - ..Default::default() - } - } - /// Prepend a `BITMAPFILEHEADER` so `image` can decode the DIB. Mirrors the /// palette-aware offset the shared `dwg_thumbnailer::dib_to_bmp` computes. fn dib_to_bmp(dib: &[u8]) -> Vec { @@ -429,35 +211,13 @@ mod tests { assert_eq!(canvas_dims(0.5), (MAX_DIM / 2, MAX_DIM)); // tall } - #[test] - fn rasterize_draws_a_valid_non_blank_dib() { - let bg = [0.1, 0.1, 0.1, 1.0]; - // A closed square (connected polyline) in a distinct colour. - let sq = wire( - &[[10.0, 10.0], [90.0, 10.0], [90.0, 90.0], [10.0, 90.0], [10.0, 10.0]], - [1.0, 0.0, 0.0, 1.0], - ); - // Trivial projector: world XY straight to pixels (Y flipped), z ignored. - let p = rasterize(&[sq], MAX_DIM, MAX_DIM, bg, false, |x, y, _| { - Some((x.round() as i32, (MAX_DIM as f64 - y).round() as i32)) - }) - .expect("some preview"); - assert_eq!(p.format, PreviewFormat::Bmp); - // DIB starts with a 40-byte BITMAPINFOHEADER. - assert_eq!(&p.data[0..4], &40u32.to_le_bytes()); - let img = image::load_from_memory(&dib_to_bmp(&p.data)).expect("decodes").to_rgb8(); - assert_eq!((img.width(), img.height()), (MAX_DIM, MAX_DIM)); - let bg_px = to_rgb(bg); - assert!(img.pixels().any(|px| px.0 != bg_px), "nothing drawn"); - assert!(img.pixels().any(|px| px.0[0] > 128 && px.0[1] < 64), "square not red"); - } - #[test] fn rle8_bmp_is_8bit_compressed_and_round_trips() { - let bg = [1.0, 1.0, 1.0, 1.0]; - let sq = wire(&[[10.0, 10.0], [90.0, 90.0]], [0.0, 0.0, 0.0, 1.0]); - let proj = |x: f64, y: f64, _z: f64| Some((x.round() as i32, y.round() as i32)); - let bmp = rasterize(&[sq], MAX_DIM, MAX_DIM, bg, false, proj).unwrap(); + let mut image = RgbImage::from_pixel(MAX_DIM, MAX_DIM, image::Rgb([255, 255, 255])); + for i in 10..90 { + image.put_pixel(i, i, image::Rgb([0, 0, 0])); + } + let bmp = encode(image, false).unwrap(); assert_eq!(bmp.format, PreviewFormat::Bmp); // 8-bit, BI_RLE8. assert_eq!(u16::from_le_bytes([bmp.data[14], bmp.data[15]]), 8, "bitcount"); @@ -476,90 +236,10 @@ mod tests { #[test] fn png_preview_decodes() { - let bg = [1.0, 1.0, 1.0, 1.0]; - let sq = wire(&[[10.0, 10.0], [90.0, 90.0]], [0.0, 0.0, 0.0, 1.0]); - let p = rasterize(&[sq], MAX_DIM, MAX_DIM, bg, true, |x, y, _| { - Some((x.round() as i32, y.round() as i32)) - }) - .unwrap(); + let image = RgbImage::from_pixel(MAX_DIM, MAX_DIM, image::Rgb([255, 255, 255])); + let p = encode(image, true).unwrap(); assert_eq!(p.format, PreviewFormat::Png); let img = image::load_from_memory_with_format(&p.data, ImageFormat::Png).expect("png decodes"); assert_eq!((img.width(), img.height()), (MAX_DIM, MAX_DIM)); } } - -/// Bresenham line, clipped to the image bounds. -fn draw_line(img: &mut RgbImage, (x0, y0): (i32, i32), (x1, y1): (i32, i32), col: Rgb) { - let (w, h) = (img.width() as i32, img.height() as i32); - let Some(((mut x0, mut y0), (x1, y1))) = - clip_line_to_rect((x0, y0), (x1, y1), w, h) - else { - return; - }; - let dx = (x1 - x0).abs(); - let dy = -(y1 - y0).abs(); - let sx = if x0 < x1 { 1 } else { -1 }; - let sy = if y0 < y1 { 1 } else { -1 }; - let mut err = dx + dy; - loop { - if x0 >= 0 && x0 < w && y0 >= 0 && y0 < h { - img.put_pixel(x0 as u32, y0 as u32, col); - } - if x0 == x1 && y0 == y1 { - break; - } - let e2 = 2 * err; - if e2 >= dy { - err += dy; - x0 += sx; - } - if e2 <= dx { - err += dx; - y0 += sy; - } - } -} - -fn clip_line_to_rect( - (x0, y0): (i32, i32), - (x1, y1): (i32, i32), - width: i32, - height: i32, -) -> Option<((i32, i32), (i32, i32))> { - if width <= 0 || height <= 0 { - return None; - } - let (x0, y0, x1, y1) = (x0 as f64, y0 as f64, x1 as f64, y1 as f64); - let (dx, dy) = (x1 - x0, y1 - y0); - let mut enter = 0.0f64; - let mut leave = 1.0f64; - for (p, q) in [ - (-dx, x0), - (dx, width.saturating_sub(1) as f64 - x0), - (-dy, y0), - (dy, height.saturating_sub(1) as f64 - y0), - ] { - if p == 0.0 { - if q < 0.0 { - return None; - } - continue; - } - let ratio = q / p; - if p < 0.0 { - enter = enter.max(ratio); - } else { - leave = leave.min(ratio); - } - if enter > leave { - return None; - } - } - let point = |t: f64| { - ( - (x0 + t * dx).round().clamp(0.0, width.saturating_sub(1) as f64) as i32, - (y0 + t * dy).round().clamp(0.0, height.saturating_sub(1) as f64) as i32, - ) - }; - Some((point(enter), point(leave))) -} diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index c9d6a894..92147a70 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3,6 +3,15 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static str)> { match source { + "Boolean" => Some(("model", "boolean")), + "Create" => Some(("model", "create")), + "Extrude" => Some(("model", "extrude")), + "Loft" => Some(("model", "loft")), + "Polysolid" => Some(("model", "polysolid")), + "Presspull" => Some(("model", "presspull")), + "Pyramid" => Some(("model", "pyramid")), + "Revolve" => Some(("model", "revolve")), + "Sweep" => Some(("model", "sweep")), " {:?}" => Some(("view", "message")), " (none — use: SHORTCUTS SET )" => Some(("common", "none-use-shortcuts-set-key-command")), " Alignment:" => Some(("common", "alignment-indented-label-title-case")), @@ -469,6 +478,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "By layer" => Some(("common", "by-layer")), "By style" => Some(("properties", "by-style")), "ByBlock" => Some(("common", "byblock")), + "ByColor" => Some(("common", "bycolor")), "ByLayer" => Some(("common", "bylayer")), "C++ Class" => Some(("properties", "c-class")), "CAD application for Architecture & Engineering" => Some(("ui", "cad-application-for-architecture-engineering")), @@ -609,6 +619,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Collapsed" => Some(("ui", "collapsed")), "Collect Leaders" => Some(("annotate", "collect-leaders")), "Color" => Some(("common", "color")), + "Color-dependent plot styles" => Some(("properties", "color-dependent-plot-styles")), "Color 1" => Some(("properties", "color-1")), "Color 2" => Some(("properties", "color-2")), "Color Book" => Some(("properties", "color-book")), @@ -706,6 +717,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "DATAEXTRACTION {rows} rows → \"{fname}\"" => Some(("common", "dataextraction-rows-rows-fname")), "DATAEXTRACTION: write failed: {e}" => Some(("common", "dataextraction-write-failed-e")), "DATALINK path to the .csv file:" => Some(("view", "datalink-path-to-the-csv-file")), + "DATALINK Specify insertion point:" => Some(("view", "datalink-specify-insertion-point")), "DATALINK: cannot read \"{path}\": {e}" => Some(("view", "datalink-cannot-read-path-e")), "DATALINK: imported {nrows}×{ncols} cells into a table at the origin." => Some(("view", "datalink-imported-nrows-ncols-cells-into-a-tab")), "DATALINK: the CSV file is empty." => Some(("view", "datalink-the-csv-file-is-empty")), @@ -716,14 +728,27 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "DDEDIT: selected entity is not text." => Some(("draw", "ddedit-selected-entity-is-not-text")), "DDEDIT: text updated." => Some(("common", "ddedit-text-updated")), "DIMALIGNED Enter dimension text (blank = measured value):" => Some(("annotate", "dimaligned-enter-dimension-text-blank-measured")), + "DIMALIGNED Enter formatted dimension text (blank = measured value):" => Some(("annotate", "dimaligned-enter-formatted-dimension-text-blank-measured")), + "DIMALIGNED Select object to dimension:" => Some(("annotate", "dimaligned-select-object-to-dimension")), + "DIMALIGNED Specify dimension line location [Mtext/Text/Angle]:" => Some(("annotate", "dimaligned-specify-dimension-line-location-tex")), "DIMALIGNED Specify dimension line location [Text/Angle]:" => Some(("annotate", "dimaligned-specify-dimension-line-location-tex")), + "DIMALIGNED Specify first extension line origin or press Enter to select object:" => Some(("annotate", "dimaligned-specify-first-extension-line-origin")), "DIMALIGNED Specify first extension line origin:" => Some(("annotate", "dimaligned-specify-first-extension-line-origin")), + "DIMALIGNED Specify second extension line origin:" => Some(("annotate", "dimaligned-specify-second-extension-line-origi")), "DIMALIGNED Specify second extension line origin [Text/Angle]:" => Some(("annotate", "dimaligned-specify-second-extension-line-origi")), "DIMALIGNED Specify text angle (degrees):" => Some(("annotate", "dimaligned-specify-text-angle-degrees")), + "DIMANGULAR Enter dimension text (blank = measured value):" => Some(("annotate", "dimangular-enter-dimension-text-blank-measured")), + "DIMANGULAR Enter formatted dimension text (blank = measured value):" => Some(("annotate", "dimangular-enter-formatted-dimension-text-blank-measured")), + "DIMANGULAR Select arc, circle, line, or specify an angle vertex:" => Some(("annotate", "dimangular-select-arc-circle-line-or-specify-angle-vertex")), + "DIMANGULAR Select second line:" => Some(("annotate", "dimangular-select-second-line")), "DIMANGULAR Specify angle vertex:" => Some(("annotate", "dimangular-specify-angle-vertex")), + "DIMANGULAR Specify dimension arc location [Mtext/Text/Angle/Quadrant]:" => Some(("annotate", "dimangular-specify-dimension-arc-location-options")), "DIMANGULAR Specify dimension arc location:" => Some(("annotate", "dimangular-specify-dimension-arc-location")), "DIMANGULAR Specify first extension line point:" => Some(("annotate", "dimangular-specify-first-extension-line-point")), + "DIMANGULAR Specify quadrant:" => Some(("annotate", "dimangular-specify-quadrant")), "DIMANGULAR Specify second extension line point:" => Some(("annotate", "dimangular-specify-second-extension-line-point")), + "DIMANGULAR Specify second point for text angle:" => Some(("annotate", "dimangular-specify-second-point-for-text-angle")), + "DIMANGULAR Specify text angle or first point:" => Some(("annotate", "dimangular-specify-text-angle-or-first-point")), "DIMBASELINE No base dimension found. Place a dimension first." => Some(("annotate", "dimbaseline-no-base-dimension-found-place-a-di")), "DIMBASELINE Specify a second extension line origin (Enter to exit):" => Some(("annotate", "dimbaseline-specify-a-second-extension-line-or")), "DIMBREAK Select dimension to break:" => Some(("annotate", "dimbreak-select-dimension-to-break")), @@ -733,8 +758,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "DIMCONTINUE No base dimension found. Place a dimension first." => Some(("annotate", "dimcontinue-no-base-dimension-found-place-a-di")), "DIMCONTINUE Specify a second extension line origin (Enter to exit):" => Some(("annotate", "dimcontinue-specify-a-second-extension-line-or")), "DIMDIAMETER Enter dimension text (blank = measured value):" => Some(("annotate", "dimdiameter-enter-dimension-text-blank-measure")), - "DIMDIAMETER Specify center point:" => Some(("annotate", "dimdiameter-specify-center-point")), - "DIMDIAMETER Specify dimension line location [Text/Angle]:" => Some(("annotate", "dimdiameter-specify-dimension-line-location-te")), + "DIMDIAMETER Select arc, circle, or polyline arc:" => Some(("annotate", "dimdiameter-select-arc-circle-or-polyline-arc")), + "DIMDIAMETER Specify dimension line location [Mtext/Text/Angle]:" => Some(("annotate", "dimdiameter-specify-dimension-line-location-text")), "DIMDIAMETER Specify point on circle:" => Some(("annotate", "dimdiameter-specify-point-on-circle")), "DIMDIAMETER Specify text angle (degrees):" => Some(("annotate", "dimdiameter-specify-text-angle-degrees")), "DIMEDIT Enter text override (blank = reset to measured):" => Some(("annotate", "dimedit-enter-text-override-blank-reset-to-mea")), @@ -742,6 +767,12 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "DIMJOGGED: created a jogged radius dimension." => Some(("annotate", "dimjogged-created-a-jogged-radius-dimension")), "DIMJOGGED: invalid radius." => Some(("annotate", "dimjogged-invalid-radius")), "DIMJOGGED: select an arc or circle first." => Some(("annotate", "dimjogged-select-an-arc-or-circle-first")), + "DIMJOGGED Enter dimension text (blank = measured value):" => Some(("annotate", "dimjogged-enter-dimension-text-blank-measured")), + "DIMJOGGED Select arc, circle, or polyline arc:" => Some(("annotate", "dimjogged-select-arc-circle-or-polyline-arc")), + "DIMJOGGED Specify center location override:" => Some(("annotate", "dimjogged-specify-center-location-override")), + "DIMJOGGED Specify dimension line location [Mtext/Text/Angle]:" => Some(("annotate", "dimjogged-specify-dimension-line-location-text")), + "DIMJOGGED Specify jog location:" => Some(("annotate", "dimjogged-specify-jog-location")), + "DIMJOGGED Specify text angle (degrees):" => Some(("annotate", "dimjogged-specify-text-angle-degrees")), "DIMJOGLINE Select linear or aligned dimension:" => Some(("annotate", "dimjogline-select-linear-or-aligned-dimension")), "DIMJOGLINE Specify jog location:" => Some(("annotate", "dimjogline-specify-jog-location")), "DIMJOGLINE: not yet implemented — nothing changed." => Some(("common", "dimjogline-not-yet-implemented-nothing-changed")), @@ -750,11 +781,13 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "DIMLINEAR Specify first extension line origin:" => Some(("annotate", "dimlinear-specify-first-extension-line-origin")), "DIMLINEAR Specify second extension line origin [Text/Angle]:" => Some(("annotate", "dimlinear-specify-second-extension-line-origin")), "DIMLINEAR Specify text angle (degrees):" => Some(("annotate", "dimlinear-specify-text-angle-degrees")), + "DIMORDINATE Enter dimension text (blank = measured value):" => Some(("annotate", "dimordinate-enter-dimension-text-blank-measured")), "DIMORDINATE Specify feature location:" => Some(("annotate", "dimordinate-specify-feature-location")), - "DIMORDINATE Specify leader endpoint:" => Some(("annotate", "dimordinate-specify-leader-endpoint")), + "DIMORDINATE Specify leader endpoint [Xdatum/Ydatum/Mtext/Text/Angle]:" => Some(("annotate", "dimordinate-specify-leader-endpoint")), + "DIMORDINATE Specify text angle (degrees):" => Some(("annotate", "dimordinate-specify-text-angle-degrees")), "DIMRADIUS Enter dimension text (blank = measured value):" => Some(("annotate", "dimradius-enter-dimension-text-blank-measured")), - "DIMRADIUS Specify center point:" => Some(("annotate", "dimradius-specify-center-point")), - "DIMRADIUS Specify dimension line location [Text/Angle]:" => Some(("annotate", "dimradius-specify-dimension-line-location-text")), + "DIMRADIUS Select arc, circle, or polyline arc:" => Some(("annotate", "dimradius-select-arc-circle-or-polyline-arc")), + "DIMRADIUS Specify dimension line location [Mtext/Text/Angle]:" => Some(("annotate", "dimradius-specify-dimension-line-location-text")), "DIMRADIUS Specify radius point:" => Some(("annotate", "dimradius-specify-radius-point")), "DIMRADIUS Specify text angle (degrees):" => Some(("annotate", "dimradius-specify-text-angle-degrees")), "DIMSPACE Enter value (0 = auto):" => Some(("annotate", "dimspace-enter-value-0-auto")), @@ -777,6 +810,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "DONUT Specify center of donut (Enter to exit):" => Some(("draw", "donut-specify-center-of-donut-enter-to-exit")), "DONUT Specify inside diameter <0>:" => Some(("draw", "donut-specify-inside-diameter-0")), "DONUT Specify outside diameter:" => Some(("draw", "donut-specify-outside-diameter")), + "DONUT Specify second point for inside diameter:" => Some(("draw", "donut-specify-second-point-for-inside-diameter")), + "DONUT Specify second point for outside diameter:" => Some(("draw", "donut-specify-second-point-for-outside-diameter")), "DRAWORDER Select reference object (move selection above):" => Some(("view", "draworder-select-reference-object-move-selection-above")), "DRAWORDER Select reference object (move selection under):" => Some(("view", "draworder-select-reference-object-move-selection-under")), "DRAWORDER [Front / Back] (Above/Under by typing):" => Some(("view", "draworder-front-back-above-under-handle-by-typ")), @@ -1006,6 +1041,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Ext line offset" => Some(("properties", "ext-line-offset")), "Ext line weight (DIMLWE)" => Some(("draw", "ext-line-weight-dimlwe")), "Extend" => Some(("modify", "extend")), + "Extended Data" => Some(("properties", "extended-data")), "Extended Entity" => Some(("properties", "extended-entity")), "Extended Lighting" => Some(("properties", "extended-lighting")), "Extension" => Some(("common", "extension")), @@ -1268,6 +1304,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "JUSTIFYTEXT: updated {n} text object(s)." => Some(("annotate", "justifytext-updated-n-text-object-s")), "Jog Angle" => Some(("properties", "jog-angle")), "Jog Line" => Some(("annotate", "jog-line")), + "Jogged Radius" => Some(("common", "jogged-radius")), "Jog X" => Some(("properties", "jog-x")), "Jog Y" => Some(("properties", "jog-y")), "Jog Z" => Some(("properties", "jog-z")), @@ -1517,6 +1554,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "MIRROR Specify first mirror-line point [%{count} objects]:" => Some(("modify", "mirror-specify-first-mirror-line-point-count-o")), "MIRROR Specify second point [p1=%{px},%{py}]:" => Some(("modify", "mirror-specify-second-point-p1-px-py")), "MLEADER Specify arrowhead point:" => Some(("annotate", "mleader-specify-arrowhead-point")), + "MLEADER Specify landing point:" => Some(("annotate", "mleader-specify-landing-point")), "MLEADER Specify next point [%{count} pts — Enter to place text]:" => Some(("annotate", "mleader-specify-next-point-count-pts-enter-to")), "MLEADERADD Select a multileader:" => Some(("annotate", "mleaderadd-select-a-multileader")), "MLEADERADD Specify arrowhead location:" => Some(("annotate", "mleaderadd-specify-arrowhead-location")), @@ -1687,6 +1725,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Name:" => Some(("common", "name-label-title-case")), "Named" => Some(("view", "named")), "Named View" => Some(("properties", "named-view")), + "Named plot styles" => Some(("properties", "named-plot-styles")), "Named views: {}" => Some(("layers", "named-views")), "Navigate" => Some(("view", "navigate")), "Nearest" => Some(("common", "nearest")), @@ -1973,6 +2012,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Plot scale" => Some(("plot", "plot-scale")), "Plot stamp" => Some(("plot", "plot-stamp")), "Plot style" => Some(("common", "plot-style")), + "Plot style is locked to color in Color-Dependent (CTB) mode" => Some(("properties", "plot-style-is-locked-to-color-in-color-dependent")), "Plot style '{}' loaded ({} color entries)." => Some(("common", "plot-style-loaded-color-entries")), "Plot style ACI {aci} updated." => Some(("file", "plot-style-aci-aci-updated")), "Plot style table" => Some(("properties", "plot-style-table")), @@ -2543,9 +2583,19 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Sync Scales" => Some(("annotate", "sync-scales")), "Synchronize" => Some(("insert", "synchronize")), "System default printer" => Some(("plot", "system-default-printer")), + "TABLE CELL Enter text for [%{row},%{column}] <%{current}>:" => Some(("annotate", "table-cell-enter-text-row-column-current")), + "TABLE Enter column width [%{width}]:" => Some(("annotate", "table-enter-column-width-width")), "TABLE Enter number of columns [%{cols}]:" => Some(("annotate", "table-enter-number-of-columns-cols")), + "TABLE Enter number of data rows [%{rows}] (%{cols} cols):" => Some(("annotate", "table-enter-number-of-data-rows-rows-cols")), "TABLE Enter number of rows (incl. header) [%{rows}] (%{cols} cols):" => Some(("annotate", "table-enter-number-of-rows-incl-header-rows-co")), + "TABLE Enter row height [%{height}]:" => Some(("annotate", "table-enter-row-height-height")), + "TABLE Insertion behavior [Point/Window] <%{mode}>:" => Some(("annotate", "table-insertion-behavior-point-window-mode")), "TABLE Specify insertion point [%{cols}×%{rows}]:" => Some(("annotate", "table-specify-insertion-point-cols-rows")), + "TABLE: block definition not found." => Some(("view", "table-block-definition-not-found")), + "TABLE: edit applied." => Some(("view", "table-edit-applied")), + "TABLE: field handle not found." => Some(("view", "table-field-handle-not-found")), + "TABLE: row/column is out of range or the cell is locked." => Some(("view", "table-row-column-out-of-range-or-cell-locked")), + "TABLE: select a table first." => Some(("view", "table-select-a-table-first")), "TABLE CELL: select a Table entity first, or row/col out of range." => Some(("view", "table-cell-select-a-table-entity-first-or-row")), "TABLE CELL: set [{row},{col}] = \"{text}\"." => Some(("view", "table-cell-set-row-col-text")), "TABLESTYLE: '{}' already exists." => Some(("layers", "tablestyle-already-exists")), @@ -2855,6 +2905,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Usage: SHORTCUTS LIST | SET | CLEAR " => Some(("view", "usage-shortcuts-list-set-key-cmd-clear-key")), "Usage: SHORTCUTS SET e.g. SHORTCUTS SET CTRL+D DIST" => Some(("view", "usage-shortcuts-set-key-command-e-g-shortcuts")), "Usage: TABLE CELL " => Some(("view", "usage-table-cell-row-col-text")), + "Usage: TABLE CELL | INSERTROW | DELETEROW | INSERTCOL | DELETECOL | MERGE | UNMERGE | ROWHEIGHT | COLWIDTH | LOCK | BLOCK | FORMULA | FIELD" => Some(("view", "usage-table-edit-actions")), "Usage: TABLESTYLE NEW " => Some(("layers", "usage-tablestyle-new-name")), "Usage: TABLESTYLE [LIST|NEW ]" => Some(("layers", "usage-tablestyle-list-new-name")), "Usage: TCASE " => Some(("annotate", "usage-tcase-upper-lower-sentence-title")), @@ -3070,6 +3121,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "those objects" => Some(("modal", "those-objects")), "units" => Some(("common", "units")), "{cmd}: cannot create folder ({e})." => Some(("file", "cmd-cannot-create-folder-e")), + "{cmd}: no hatch objects." => Some(("view", "cmd-no-hatch-objects")), "{cmd}: no text or dimension objects." => Some(("view", "cmd-no-text-or-dimension-objects")), "{cmd}: not yet implemented." => Some(("view", "cmd-not-yet-implemented")), "{cmd}: packaged {copied} file(s) into {}" => Some(("file", "cmd-packaged-copied-file-s-into")), diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 5a415426..2ec212d1 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -1,11 +1,11 @@ // DIMALIGNED command — aligned dimension (measures true distance between two points). use acadrust::entities::{Dimension, DimensionAligned}; -use acadrust::types::Vector3; +use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; use glam::DVec3; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdResult, DimensionAssociationInput, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -38,6 +38,10 @@ pub struct AlignedDimensionCommand { text_angle: Option, /// True while the next typed value is captured as the text angle. awaiting_angle: bool, + selecting_object: bool, + picked_entity: Option, + source_handle: Option, + mtext_override: bool, } impl AlignedDimensionCommand { @@ -49,6 +53,10 @@ impl AlignedDimensionCommand { awaiting_text: false, text_angle: None, awaiting_angle: false, + selecting_object: false, + picked_entity: None, + source_handle: None, + mtext_override: false, } } } @@ -64,18 +72,29 @@ impl CadCommand for AlignedDimensionCommand { fn prompt(&self) -> String { if self.awaiting_text { - return t!("DIMALIGNED Enter dimension text (blank = measured value):").into_owned(); + return if self.mtext_override { + t!("DIMALIGNED Enter formatted dimension text (blank = measured value):") + .into_owned() + } else { + t!("DIMALIGNED Enter dimension text (blank = measured value):").into_owned() + }; } if self.awaiting_angle { return t!("DIMALIGNED Specify text angle (degrees):").into_owned(); } + if self.selecting_object { + return t!("DIMALIGNED Select object to dimension:").into_owned(); + } match self.step { - Step::First => t!("DIMALIGNED Specify first extension line origin:").into_owned(), + Step::First => t!( + "DIMALIGNED Specify first extension line origin or press Enter to select object:" + ) + .into_owned(), Step::Second(_) => { - t!("DIMALIGNED Specify second extension line origin [Text/Angle]:").into_owned() + t!("DIMALIGNED Specify second extension line origin:").into_owned() } Step::DimLine { .. } => { - t!("DIMALIGNED Specify dimension line location [Text/Angle]:").into_owned() + t!("DIMALIGNED Specify dimension line location [Mtext/Text/Angle]:").into_owned() } } } @@ -87,6 +106,9 @@ impl CadCommand for AlignedDimensionCommand { CmdResult::NeedPoint } Step::Second(p1) => { + if pt.distance_squared(p1) <= 1e-24 { + return CmdResult::NeedPoint; + } self.step = Step::DimLine { p1, p2: pt }; CmdResult::NeedPoint } @@ -95,29 +117,25 @@ impl CadCommand for AlignedDimensionCommand { let p2 = self.plane.to_local(p2); let pt = self.plane.to_local(pt); let mut dim = DimensionAligned::new(v3(p1), v3(p2)); - // The dimension line runs through the cursor: store it as the - // definition point and let the renderer project it onto the - // line perpendicular (same as the preview and DIMLINEAR). - // - // The old path called `set_offset` with the straight-line - // p2→cursor *distance*, which `set_offset` then re-applies along - // the line perpendicular — placing the line at the wrong - // perpendicular distance and always on the +perp side, so it - // never matched the preview the user was dragging. (#150) + // Store the cursor position so commit matches the preview. dim.definition_point = v3(pt); dim.base.definition_point = v3(pt); let (d1, d2) = dim_line_endpoints(p1, p2, pt); dim.base.text_middle_point = v3((d1 + d2) * 0.5); dim.base.insertion_point = dim.base.text_middle_point; dim.base.actual_measurement = dim.measurement(); - dim.base.user_text = self.text_override.clone(); + crate::entities::dimension::set_dimension_text_override( + &mut dim.base, + self.text_override.clone(), + ); // An explicit text angle overrides the default rotation. if let Some(a) = self.text_angle { dim.base.text_rotation = a; } - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Dimension( - Dimension::Aligned(dim), - ))) + CmdResult::CommitDimension { + entity: self.plane.place_entity(EntityType::Dimension(Dimension::Aligned(dim))), + association: DimensionAssociationInput::Infer(self.source_handle), + } } } } @@ -132,6 +150,10 @@ impl CadCommand for AlignedDimensionCommand { self.awaiting_angle = false; return CmdResult::NeedPoint; } + if matches!(self.step, Step::First) { + self.selecting_object = true; + return CmdResult::NeedPoint; + } CmdResult::Cancel } @@ -173,8 +195,17 @@ impl CadCommand for AlignedDimensionCommand { self.awaiting_angle = false; return Some(CmdResult::NeedPoint); } + if !matches!(self.step, Step::DimLine { .. }) { + return None; + } match text.trim().to_uppercase().as_str() { - "T" | "TEXT" | "M" | "MTEXT" => { + "T" | "TEXT" => { + self.mtext_override = false; + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "M" | "MTEXT" => { + self.mtext_override = true; self.awaiting_text = true; Some(CmdResult::NeedPoint) } @@ -186,6 +217,38 @@ impl CadCommand for AlignedDimensionCommand { } } + fn needs_entity_pick(&self) -> bool { + self.selecting_object + } + + 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((p1, p2)) = super::linear_dim::dimension_source_points(&entity, point) else { + return CmdResult::NeedPoint; + }; + if p1.distance_squared(p2) <= 1e-24 { + return CmdResult::NeedPoint; + } + self.source_handle = Some(handle); + self.selecting_object = false; + self.step = Step::DimLine { p1, p2 }; + CmdResult::NeedPoint + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { let (p1, p2) = match self.step { Step::First => return None, @@ -222,6 +285,7 @@ impl CadCommand for AlignedDimensionCommand { Some(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -271,16 +335,19 @@ fn dim_line_endpoints(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> (DVec3, DVec3) { } fn preview_aligned(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> WireModel { - // Show ext lines + dim line. let (d1, d2) = dim_line_endpoints(p1, p2, dim_pt); - // Preview WireModel points are screen/GPU-side: downcast to f32. - let p1 = p1.as_vec3(); - let p2 = p2.as_vec3(); - let d1 = d1.as_vec3(); - let d2 = d2.as_vec3(); + let axis = (d2 - d1).normalize_or_zero(); + let perp = DVec3::new(-axis.y, axis.x, 0.0); + let nan = [f32::NAN, 0.0, 0.0]; + let arrow = 0.22; + let text = (d1 + d2) * 0.5 + perp * 0.15; + let half_width = ((p2 - p1).length().log10().max(0.0) + 1.0) * 0.18; + let half_height = 0.16; + let to_point = |point: DVec3| point.as_vec3().to_array(); WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -290,19 +357,33 @@ fn preview_aligned(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> WireModel { render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), name: "dimaligned_preview".into(), points: vec![ - [p1.x, p1.y, p1.z], - [d1.x, d1.y, d1.z], - [f32::NAN, 0.0, 0.0], - [p2.x, p2.y, p2.z], - [d2.x, d2.y, d2.z], - [f32::NAN, 0.0, 0.0], - [d1.x, d1.y, d1.z], - [d2.x, d2.y, d2.z], + to_point(p1), + to_point(d1), + nan, + to_point(p2), + to_point(d2), + nan, + to_point(d1), + to_point(d2), + nan, + to_point(d1 + axis * arrow + perp * arrow * 0.45), + to_point(d1), + to_point(d1 + axis * arrow - perp * arrow * 0.45), + nan, + to_point(d2 - axis * arrow + perp * arrow * 0.45), + to_point(d2), + to_point(d2 - axis * arrow - perp * arrow * 0.45), + nan, + to_point(text - axis * half_width - perp * half_height), + to_point(text + axis * half_width - perp * half_height), + to_point(text + axis * half_width + perp * half_height), + to_point(text - axis * half_width + perp * half_height), + to_point(text - axis * half_width - perp * half_height), ], points_low: Vec::new(), color: WireModel::CYAN, diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index b526532d..6bd3ef2e 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -1,12 +1,19 @@ -use acadrust::entities::{Dimension, DimensionAngular3Pt}; -use acadrust::types::Vector3; +use acadrust::entities::{Dimension, DimensionAngular2Ln, DimensionAngular3Pt}; +use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; +use cadkernel::geom2d::{ + closest_point, line_line, Arc as KernelArc, BulgeArc, Curve as KernelCurve, + Line as KernelLine, +}; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{ + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, + DimensionAssociationSource, WorkingPlane, +}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; -use glam::{DVec3, Vec3}; use crate::t; +use glam::DVec3; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/dim_angular.svg")); @@ -22,20 +29,86 @@ pub fn tool() -> ToolDef { enum Step { Vertex, FirstRay(DVec3), - SecondRay { + SecondRay { vertex: DVec3, first: DVec3 }, + CircleSecondRay { vertex: DVec3, first: DVec3, + source: Handle, }, - ArcPoint { + SecondLine { + first_start: DVec3, + first_end: DVec3, + first_source: Handle, + first_refs: [SourceLocator; 2], + }, + ArcPoint3 { vertex: DVec3, first: DVec3, second: DVec3, }, + ArcPoint2 { + first_start: DVec3, + first_end: DVec3, + second_start: DVec3, + second_end: DVec3, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct SourceLocator { + marker: Option, + parameter: f64, +} + +impl SourceLocator { + const INFERRED: Self = Self { marker: None, parameter: 0.0 }; + + const fn explicit(marker: i32, parameter: f64) -> Self { + Self { marker: Some(marker), parameter } + } + + fn bind(self, handle: Handle) -> DimensionAssociationSource { + match self.marker { + Some(marker) => DimensionAssociationSource::explicit(handle, marker, self.parameter), + None => DimensionAssociationSource::inferred(handle), + } + } +} + +enum PickedCurve { + Line { + start: DVec3, + end: DVec3, + refs: [SourceLocator; 2], + normal: Option, + }, + Arc { + center: DVec3, + first: DVec3, + second: DVec3, + refs: [SourceLocator; 3], + normal: DVec3, + }, + Circle { + center: DVec3, + normal: DVec3, + }, } pub struct AngularDimensionCommand { step: Step, plane: WorkingPlane, + selecting_object: bool, + picked_entity: Option, + source_refs: Vec>, + text_override: Option, + awaiting_text: bool, + mtext_override: bool, + text_angle: Option, + awaiting_angle: bool, + angle_origin: Option, + awaiting_quadrant: bool, + quadrant_lock: Option<(f64, f64)>, } impl AngularDimensionCommand { @@ -43,6 +116,203 @@ impl AngularDimensionCommand { Self { step: Step::Vertex, plane: WorkingPlane::default(), + selecting_object: true, + picked_entity: None, + source_refs: Vec::new(), + text_override: None, + awaiting_text: false, + mtext_override: false, + text_angle: None, + awaiting_angle: false, + angle_origin: None, + awaiting_quadrant: false, + quadrant_lock: None, + } + } + + fn finish_three_point( + &self, + vertex: DVec3, + first: DVec3, + second: DVec3, + arc_point: DVec3, + ) -> CmdResult { + let vertex = self.plane.to_local(vertex); + let first = self.plane.to_local(first); + let second = self.plane.to_local(second); + let picked_text = self.plane.to_local(arc_point); + let arc_point = locked_arc_point(vertex, picked_text, self.quadrant_lock); + if two_line_frame(vertex, first, vertex, second, arc_point).is_none() { + return CmdResult::NeedPoint; + } + let mut dim = DimensionAngular3Pt::new(v3(vertex), v3(first), v3(second)); + dim.definition_point = v3(arc_point); + dim.base.definition_point = dim.definition_point; + dim.base.text_middle_point = dim.definition_point; + dim.base.insertion_point = dim.definition_point; + if self.quadrant_lock.is_some_and(|frame| { + !point_angle_in_frame(vertex, picked_text, frame) + }) { + dim.base.text_middle_point = v3(picked_text); + dim.base.insertion_point = dim.base.text_middle_point; + dim.base.text_user_positioned = true; + } + dim.base.actual_measurement = dim.measurement_degrees(); + crate::entities::dimension::set_dimension_text_override( + &mut dim.base, + self.text_override.clone(), + ); + if let Some(angle) = self.text_angle { + dim.base.text_rotation = angle; + } + self.commit(Dimension::Angular3Pt(dim)) + } + + fn finish_two_line( + &self, + first_start: DVec3, + first_end: DVec3, + second_start: DVec3, + second_end: DVec3, + arc_point: DVec3, + ) -> CmdResult { + let first_start = self.plane.to_local(first_start); + let first_end = self.plane.to_local(first_end); + let second_start = self.plane.to_local(second_start); + let second_end = self.plane.to_local(second_end); + let picked_text = self.plane.to_local(arc_point); + let Some((vertex, _, _)) = two_line_frame( + first_start, + first_end, + second_start, + second_end, + picked_text, + ) else { + return CmdResult::NeedPoint; + }; + let arc_point = locked_arc_point(vertex, picked_text, self.quadrant_lock); + if two_line_frame( + first_start, + first_end, + second_start, + second_end, + arc_point, + ) + .is_none() + { + return CmdResult::NeedPoint; + } + let mut dim = DimensionAngular2Ln::default(); + dim.first_point = v3(first_start); + dim.second_point = v3(first_end); + dim.angle_vertex = v3(second_start); + dim.definition_point = v3(second_end); + dim.dimension_arc = v3(arc_point); + dim.base.definition_point = dim.definition_point; + dim.base.text_middle_point = dim.dimension_arc; + dim.base.insertion_point = dim.dimension_arc; + if self.quadrant_lock.is_some_and(|frame| { + !point_angle_in_frame(vertex, picked_text, frame) + }) { + dim.base.text_middle_point = v3(picked_text); + dim.base.insertion_point = dim.base.text_middle_point; + dim.base.text_user_positioned = true; + } + dim.base.actual_measurement = dim.measurement_degrees(); + crate::entities::dimension::set_dimension_text_override( + &mut dim.base, + self.text_override.clone(), + ); + if let Some(angle) = self.text_angle { + dim.base.text_rotation = angle; + } + self.commit(Dimension::Angular2Ln(dim)) + } + + fn commit(&self, dimension: Dimension) -> CmdResult { + let entity = self.plane.place_entity(EntityType::Dimension(dimension)); + CmdResult::CommitDimension { + entity, + association: DimensionAssociationInput::Explicit(self.source_refs.clone()), + } + } + + fn placement_step(&self) -> bool { + matches!(self.step, Step::ArcPoint3 { .. } | Step::ArcPoint2 { .. }) + } + + fn editor_anchor(&self) -> DVec3 { + match self.step { + Step::ArcPoint3 { vertex, first, second } => { + let radius = vertex.distance(first).max(vertex.distance(second)).max(1.0); + let first_angle = self.plane.angle(vertex, first).unwrap_or(0.0); + let second_angle = self.plane.angle(vertex, second).unwrap_or(first_angle); + let sweep = (second_angle - first_angle).rem_euclid(std::f64::consts::TAU); + let angle = first_angle + sweep * 0.5; + self.plane.to_world( + self.plane.to_local(vertex) + + DVec3::new(angle.cos() * radius, angle.sin() * radius, 0.0), + ) + } + Step::ArcPoint2 { + first_start, + first_end, + second_start, + second_end, + } => { + let first_start = self.plane.to_local(first_start); + let first_end = self.plane.to_local(first_end); + let second_start = self.plane.to_local(second_start); + let second_end = self.plane.to_local(second_end); + two_line_frame( + first_start, + first_end, + second_start, + second_end, + (first_start + second_start) * 0.5, + ) + .map(|(vertex, start, end)| { + self.plane.to_world(vertex + DVec3::new( + ((start + end) * 0.5).cos(), + ((start + end) * 0.5).sin(), + 0.0, + )) + }) + .unwrap_or_else(|| self.plane.to_world(first_start)) + } + _ => self.plane.origin, + } + } + + fn set_quadrant_from_point(&mut self, point: DVec3) -> bool { + let point = self.plane.to_local(point); + let frame = match self.step { + Step::ArcPoint3 { vertex, first, second } => two_line_frame( + self.plane.to_local(vertex), + self.plane.to_local(first), + self.plane.to_local(vertex), + self.plane.to_local(second), + point, + ), + Step::ArcPoint2 { + first_start, + first_end, + second_start, + second_end, + } => two_line_frame( + self.plane.to_local(first_start), + self.plane.to_local(first_end), + self.plane.to_local(second_start), + self.plane.to_local(second_end), + point, + ), + _ => None, + }; + if let Some((_, start, end)) = frame { + self.quadrant_lock = Some((start, end)); + true + } else { + false } } } @@ -57,59 +327,156 @@ impl CadCommand for AngularDimensionCommand { } fn prompt(&self) -> String { + if self.awaiting_quadrant { + return t!("DIMANGULAR Specify quadrant:").into_owned(); + } + if self.awaiting_text { + return if self.mtext_override { + t!("DIMANGULAR Enter formatted dimension text (blank = measured value):") + .into_owned() + } else { + t!("DIMANGULAR Enter dimension text (blank = measured value):").into_owned() + }; + } + if self.awaiting_angle { + return if self.angle_origin.is_some() { + t!("DIMANGULAR Specify second point for text angle:").into_owned() + } else { + t!("DIMANGULAR Specify text angle or first point:").into_owned() + }; + } + if self.selecting_object { + return match self.step { + Step::SecondLine { .. } => t!("DIMANGULAR Select second line:").into_owned(), + _ => t!( + "DIMANGULAR Select arc, circle, line, or specify an angle vertex:" + ) + .into_owned(), + }; + } match self.step { - Step::Vertex => t!("DIMANGULAR Specify angle vertex:").into_owned(), + Step::Vertex => t!( + "DIMANGULAR Specify angle vertex:" + ) + .into_owned(), Step::FirstRay(_) => { t!("DIMANGULAR Specify first extension line point:").into_owned() } - Step::SecondRay { .. } => { + Step::SecondRay { .. } | Step::CircleSecondRay { .. } => { t!("DIMANGULAR Specify second extension line point:").into_owned() } - Step::ArcPoint { .. } => t!("DIMANGULAR Specify dimension arc location:").into_owned(), + Step::SecondLine { .. } => t!("DIMANGULAR Select second line:").into_owned(), + Step::ArcPoint3 { .. } | Step::ArcPoint2 { .. } => t!( + "DIMANGULAR Specify dimension arc location [Mtext/Text/Angle/Quadrant]:" + ) + .into_owned(), } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { + fn on_point(&mut self, point: DVec3) -> CmdResult { + if self.awaiting_quadrant { + if self.set_quadrant_from_point(point) { + self.awaiting_quadrant = false; + } + return CmdResult::NeedPoint; + } + if self.awaiting_angle { + if let Some(origin) = self.angle_origin { + if let Some(angle) = self.plane.angle(origin, point) { + self.text_angle = Some(angle); + self.awaiting_angle = false; + self.angle_origin = None; + } + } else { + self.angle_origin = Some(point); + } + return CmdResult::NeedPoint; + } + if self.selecting_object && matches!(self.step, Step::Vertex) { + self.selecting_object = false; + self.step = Step::FirstRay(point); + return CmdResult::NeedPoint; + } match self.step { Step::Vertex => { - self.step = Step::FirstRay(pt); + self.step = Step::FirstRay(point); CmdResult::NeedPoint } Step::FirstRay(vertex) => { - self.step = Step::SecondRay { vertex, first: pt }; + if point.distance_squared(vertex) <= 1.0e-24 { + return CmdResult::NeedPoint; + } + self.step = Step::SecondRay { vertex, first: point }; CmdResult::NeedPoint } Step::SecondRay { vertex, first } => { - self.step = Step::ArcPoint { - vertex, - first, - second: pt, - }; + if point.distance_squared(vertex) <= 1.0e-24 { + return CmdResult::NeedPoint; + } + let first_local = self.plane.vector_to_local(first - vertex); + let second_local = self.plane.vector_to_local(point - vertex); + if line_line( + [0.0, 0.0], + [first_local.x, first_local.y], + [0.0, 0.0], + [second_local.x, second_local.y], + ) + .is_none() + { + return CmdResult::NeedPoint; + } + self.step = Step::ArcPoint3 { vertex, first, second: point }; CmdResult::NeedPoint } - Step::ArcPoint { - vertex, - first, - second, - } => { - let vertex = self.plane.to_local(vertex); - let first = self.plane.to_local(first); - let second = self.plane.to_local(second); - let pt = self.plane.to_local(pt); - let mut dim = DimensionAngular3Pt::new(v3(vertex), v3(first), v3(second)); - dim.definition_point = v3(pt); - dim.base.definition_point = v3(pt); - dim.base.text_middle_point = v3(pt); - dim.base.insertion_point = v3(pt); - dim.base.actual_measurement = dim.measurement_degrees(); - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Dimension( - Dimension::Angular3Pt(dim), - ))) + Step::CircleSecondRay { vertex, first, source } => { + if point.distance_squared(vertex) <= 1.0e-24 { + return CmdResult::NeedPoint; + } + self.source_refs = vec![ + Some(DimensionAssociationSource::inferred(source)), + Some(DimensionAssociationSource::inferred(source)), + None, + ]; + self.step = Step::ArcPoint3 { vertex, first, second: point }; + CmdResult::NeedPoint } + Step::SecondLine { .. } => CmdResult::NeedPoint, + Step::ArcPoint3 { vertex, first, second } => { + self.finish_three_point(vertex, first, second, point) + } + Step::ArcPoint2 { + first_start, + first_end, + second_start, + second_end, + } => self.finish_two_line( + first_start, + first_end, + second_start, + second_end, + point, + ), } } fn on_enter(&mut self) -> CmdResult { + if self.awaiting_quadrant { + self.awaiting_quadrant = false; + return CmdResult::NeedPoint; + } + if self.awaiting_text { + self.awaiting_text = false; + return CmdResult::NeedPoint; + } + if self.awaiting_angle { + self.awaiting_angle = false; + self.angle_origin = None; + return CmdResult::NeedPoint; + } + if matches!(self.step, Step::Vertex) { + self.selecting_object = !self.selecting_object; + return CmdResult::NeedPoint; + } CmdResult::Cancel } @@ -117,52 +484,655 @@ impl CadCommand for AngularDimensionCommand { CmdResult::Cancel } - fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let pt = pt.as_vec3(); - match self.step { - Step::Vertex => None, - Step::FirstRay(vertex) => Some(preview_wire(vec![vertex.as_vec3(), pt])), - Step::SecondRay { vertex, first } => Some(preview_wire(vec![ - vertex.as_vec3(), - first.as_vec3(), - Vec3::new(f32::NAN, f32::NAN, f32::NAN), - vertex.as_vec3(), - pt, - ])), - Step::ArcPoint { - vertex, - first, - second, + fn wants_text_input(&self) -> bool { + true + } + + fn wants_text_with_spaces(&self) -> bool { + self.awaiting_text + } + + fn options(&self) -> Vec { + if self.placement_step() && !self.awaiting_text && !self.awaiting_angle && !self.awaiting_quadrant { + vec![ + CmdOption::new("MText", "MTEXT"), + CmdOption::new("Text", "TEXT"), + CmdOption::new("Angle", "ANGLE"), + CmdOption::new("Quadrant", "QUADRANT"), + ] + } else { + Vec::new() + } + } + + fn point_step_accepts_keywords(&self) -> bool { + self.placement_step() + && !self.awaiting_text + && !self.awaiting_angle + && !self.awaiting_quadrant + } + + fn on_text_input(&mut self, text: &str) -> Option { + 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 { + self.text_angle = if text.trim().is_empty() { + None + } else { + crate::entities::common::parse_typed_angle(text.trim()) + }; + self.awaiting_angle = false; + self.angle_origin = None; + return Some(CmdResult::NeedPoint); + } + if !self.placement_step() { + return None; + } + match text.trim().to_ascii_uppercase().as_str() { + "T" | "TEXT" => { + self.mtext_override = false; + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "M" | "MTEXT" => { + self.mtext_override = true; + Some(CmdResult::SuspendForMTextInput { + pos: self.editor_anchor(), + initial: self.text_override.clone().unwrap_or_default(), + height: 2.5, + }) + } + "A" | "ANGLE" => { + self.awaiting_angle = true; + self.angle_origin = None; + Some(CmdResult::NeedPoint) + } + "Q" | "QUADRANT" => { + self.awaiting_quadrant = true; + 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 { + self.selecting_object + } + + fn entity_pick_accepts_points(&self) -> bool { + self.selecting_object && matches!(self.step, Step::Vertex) + } + + 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 { + if handle.is_null() { + if matches!(self.step, Step::Vertex) { + self.selecting_object = false; + self.step = Step::FirstRay(point); + } + return CmdResult::NeedPoint; + } + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some(curve) = picked_curve(&entity, point) else { + return CmdResult::NeedPoint; + }; + if !curve_is_coplanar(&curve, self.plane) { + return CmdResult::NeedPoint; + } + + match (&self.step, curve) { + ( + Step::SecondLine { + first_start, + first_end, + first_source, + first_refs, + }, + PickedCurve::Line { + start: second_start, + end: second_end, + refs: second_refs, + .. + }, + ) => { + if (*first_source == handle && *first_refs == second_refs) + || lines_parallel_in_plane( + self.plane, + *first_start, + *first_end, + second_start, + second_end, + ) + { + return CmdResult::NeedPoint; + } + self.source_refs = vec![ + Some(first_refs[0].bind(*first_source)), + Some(first_refs[1].bind(*first_source)), + Some(second_refs[0].bind(handle)), + Some(second_refs[1].bind(handle)), + ]; + self.selecting_object = false; + self.step = Step::ArcPoint2 { + first_start: *first_start, + first_end: *first_end, + second_start, + second_end, + }; + CmdResult::NeedPoint + } + ( + Step::Vertex, + PickedCurve::Line { + start: first_start, + end: first_end, + refs, + .. + }, + ) => { + self.step = Step::SecondLine { + first_start, + first_end, + first_source: handle, + first_refs: refs, + }; + CmdResult::NeedPoint + } + ( + Step::Vertex, + PickedCurve::Arc { + center, + first, + second, + refs, + .. + }, + ) => { + self.source_refs = refs + .into_iter() + .map(|reference| Some(reference.bind(handle))) + .collect(); + self.selecting_object = false; + self.step = Step::ArcPoint3 { vertex: center, first, second }; + CmdResult::NeedPoint + } + (Step::Vertex, PickedCurve::Circle { center, .. }) => { + if point.distance_squared(center) <= 1.0e-24 { + return CmdResult::NeedPoint; + } + self.selecting_object = false; + self.step = Step::CircleSecondRay { + vertex: center, + first: point, + source: handle, + }; + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + } + } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + if self.awaiting_angle { + return self + .angle_origin + .map(|origin| preview_wire(vec![origin, point])); + } + let points = match self.step { + Step::Vertex | Step::SecondLine { .. } => return None, + Step::FirstRay(vertex) => vec![vertex, point], + Step::SecondRay { vertex, first } => vec![vertex, first, nan(), vertex, point], + Step::CircleSecondRay { vertex, first, .. } => { + vec![vertex, first, nan(), vertex, point] + } + Step::ArcPoint3 { vertex, first, second } => { + let vertex = self.plane.to_local(vertex); + let first = self.plane.to_local(first); + let second = self.plane.to_local(second); + let point = locked_arc_point( + vertex, + self.plane.to_local(point), + self.quadrant_lock, + ); + angular_preview(vertex, first, second, point) + .into_iter() + .map(|value| if value.is_nan() { value } else { self.plane.to_world(value) }) + .collect() + } + Step::ArcPoint2 { + first_start, + first_end, + second_start, + second_end, } => { - let points = angular_preview( - self.plane.to_local(vertex).as_vec3(), - self.plane.to_local(first).as_vec3(), - self.plane.to_local(second).as_vec3(), - self.plane.to_local(pt.as_dvec3()).as_vec3(), + let first_start = self.plane.to_local(first_start); + let first_end = self.plane.to_local(first_end); + let second_start = self.plane.to_local(second_start); + let second_end = self.plane.to_local(second_end); + let point = self.plane.to_local(point); + let point = two_line_frame( + first_start, + first_end, + second_start, + second_end, + point, + ) + .map(|(vertex, _, _)| locked_arc_point(vertex, point, self.quadrant_lock)) + .unwrap_or(point); + two_line_preview( + first_start, + first_end, + second_start, + second_end, + point, ) .into_iter() - .map(|point| { - if point.x.is_nan() { - point - } else { - self.plane.to_world(point.as_dvec3()).as_vec3() - } - }) - .collect(); - Some(preview_wire(points)) + .map(|value| if value.is_nan() { value } else { self.plane.to_world(value) }) + .collect() } - } + }; + Some(preview_wire(points)) } } -fn v3(pt: DVec3) -> Vector3 { - Vector3::new(pt.x, pt.y, pt.z) +fn picked_curve(entity: &EntityType, click: DVec3) -> Option { + let point = |value: Vector3| DVec3::new(value.x, value.y, value.z); + let normal = |value: Vector3| DVec3::new(value.x, value.y, value.z); + match entity { + EntityType::Line(line) => { + let start = point(line.start); + let end = point(line.end); + (start.distance_squared(end) > 1.0e-24).then_some(PickedCurve::Line { + start, + end, + refs: [SourceLocator::INFERRED; 2], + normal: None, + }) + } + EntityType::Arc(arc) => Some(PickedCurve::Arc { + center: point(arc.center_wcs()), + first: point(arc.start_point_wcs()), + second: point(arc.end_point_wcs()), + refs: [SourceLocator::INFERRED; 3], + normal: normal(arc.normal), + }), + EntityType::Circle(circle) => Some(PickedCurve::Circle { + center: point(circle.center_wcs()), + normal: normal(circle.normal), + }), + EntityType::LwPolyline(polyline) => { + let click = crate::scene::view::transform::wcs_point_to_ocs( + (click.x, click.y, click.z), + (polyline.normal.x, polyline.normal.y, polyline.normal.z), + ); + let points: Vec<_> = polyline + .vertices + .iter() + .map(|vertex| [vertex.location.x, vertex.location.y]) + .collect(); + let bulges: Vec<_> = polyline.vertices.iter().map(|vertex| vertex.bulge).collect(); + let segment = nearest_segment_index( + &points, + &bulges, + polyline.is_closed, + [click.0, click.1], + )?; + let first = polyline.vertices[segment]; + let second = polyline.vertices[(segment + 1) % polyline.vertices.len()]; + polyline_segment( + [first.location.x, first.location.y], + [second.location.x, second.location.y], + first.bulge, + polyline.elevation, + polyline.normal, + segment, + polyline.vertices.len(), + ) + } + EntityType::Polyline2D(polyline) => { + let vertices = &polyline.vertices; + let click = crate::scene::view::transform::wcs_point_to_ocs( + (click.x, click.y, click.z), + (polyline.normal.x, polyline.normal.y, polyline.normal.z), + ); + let points: Vec<_> = vertices + .iter() + .map(|vertex| [vertex.location.x, vertex.location.y]) + .collect(); + let bulges: Vec<_> = vertices.iter().map(|vertex| vertex.bulge).collect(); + let segment = nearest_segment_index( + &points, + &bulges, + polyline.is_closed(), + [click.0, click.1], + )?; + let first = &vertices[segment]; + let second = &vertices[(segment + 1) % vertices.len()]; + polyline_segment( + [first.location.x, first.location.y], + [second.location.x, second.location.y], + first.bulge, + polyline.elevation, + polyline.normal, + segment, + vertices.len(), + ) + } + _ => None, + } } -fn preview_wire(points: Vec) -> WireModel { +fn nearest_segment_index( + points: &[[f64; 2]], + bulges: &[f64], + closed: bool, + click: [f64; 2], +) -> Option { + if points.len() < 2 { + return None; + } + let count = if closed { points.len() } else { points.len() - 1 }; + (0..count).min_by(|first, second| { + polyline_segment_distance_squared( + points[*first], + points[(*first + 1) % points.len()], + bulges.get(*first).copied().unwrap_or(0.0), + click, + ) + .total_cmp(&polyline_segment_distance_squared( + points[*second], + points[(*second + 1) % points.len()], + bulges.get(*second).copied().unwrap_or(0.0), + click, + )) + }) +} + +fn polyline_segment_curve(first: [f64; 2], second: [f64; 2], bulge: f64) -> KernelCurve { + if let Some(arc) = BulgeArc::from_bulge(first, second, bulge) { + let (start_angle, end_angle) = if arc.sweep >= 0.0 { + (arc.start_angle, arc.end_angle) + } else { + (arc.end_angle, arc.start_angle) + }; + KernelCurve::Arc(KernelArc { + centre: arc.center, + radius: arc.radius, + start_angle, + end_angle, + }) + } else { + KernelCurve::Line(KernelLine { start: first, end: second }) + } +} + +fn polyline_segment_distance_squared( + first: [f64; 2], + second: [f64; 2], + bulge: f64, + point: [f64; 2], +) -> f64 { + closest_point(&polyline_segment_curve(first, second, bulge), point) + .distance + .powi(2) +} + +fn polyline_segment( + first: [f64; 2], + second: [f64; 2], + bulge: f64, + elevation: f64, + normal: Vector3, + segment: usize, + vertex_count: usize, +) -> Option { + let to_world = |point: [f64; 2]| { + let value = crate::scene::view::transform::ocs_point_to_wcs( + (point[0], point[1], elevation), + (normal.x, normal.y, normal.z), + ); + DVec3::new(value.0, value.1, value.2) + }; + let first_world = to_world(first); + let second_world = to_world(second); + if first_world.distance_squared(second_world) <= 1.0e-24 { + return None; + } + let refs = [ + SourceLocator::explicit(segment as i32, 0.0), + SourceLocator::explicit(((segment + 1) % vertex_count) as i32, 0.0), + ]; + let normal_world = DVec3::new(normal.x, normal.y, normal.z); + if bulge.abs() <= 1.0e-12 { + return Some(PickedCurve::Line { + start: first_world, + end: second_world, + refs, + normal: Some(normal_world), + }); + } + let center = BulgeArc::from_bulge(first, second, bulge)?.center; + Some(PickedCurve::Arc { + center: to_world(center), + first: first_world, + second: second_world, + refs: [ + SourceLocator::explicit(-4, segment as f64), + refs[0], + refs[1], + ], + normal: normal_world, + }) +} + +fn curve_is_coplanar(curve: &PickedCurve, plane: WorkingPlane) -> bool { + let (points, normal): (Vec, Option) = match curve { + PickedCurve::Line { start, end, normal, .. } => (vec![*start, *end], *normal), + PickedCurve::Arc { center, first, second, normal, .. } => { + (vec![*center, *first, *second], Some(*normal)) + } + PickedCurve::Circle { center, normal } => (vec![*center], Some(*normal)), + }; + if let Some(normal) = normal { + let alignment = normal.normalize_or_zero().dot(plane.z).abs(); + if alignment < 1.0 - 1.0e-7 { + return false; + } + } + let local: Vec<_> = points.into_iter().map(|point| plane.to_local(point)).collect(); + let scale = local + .iter() + .map(|point| point.x.abs().max(point.y.abs()).max(1.0)) + .fold(1.0, f64::max); + local.iter().all(|point| point.z.abs() <= 1.0e-9 * scale) +} + +fn lines_parallel_in_plane( + plane: WorkingPlane, + first_start: DVec3, + first_end: DVec3, + second_start: DVec3, + second_end: DVec3, +) -> bool { + let first_start = plane.to_local(first_start); + let first_end = plane.to_local(first_end); + let second_start = plane.to_local(second_start); + let second_end = plane.to_local(second_end); + let first = first_end - first_start; + let second = second_end - second_start; + line_line( + [first_start.x, first_start.y], + [first.x, first.y], + [second_start.x, second_start.y], + [second.x, second.y], + ) + .is_none() +} + +fn locked_arc_point( + vertex: DVec3, + point: DVec3, + frame: Option<(f64, f64)>, +) -> DVec3 { + let Some((start, end)) = frame else { return point }; + let radius = ((point.x - vertex.x).powi(2) + (point.y - vertex.y).powi(2)).sqrt(); + if radius <= 1.0e-12 { + return point; + } + let angle = (start + end) * 0.5; + DVec3::new( + vertex.x + angle.cos() * radius, + vertex.y + angle.sin() * radius, + vertex.z, + ) +} + +fn point_angle_in_frame(vertex: DVec3, point: DVec3, frame: (f64, f64)) -> bool { + let angle = (point.y - vertex.y).atan2(point.x - vertex.x); + let sweep = frame.1 - frame.0; + (angle - frame.0).rem_euclid(std::f64::consts::TAU) <= sweep + 1.0e-9 +} + +fn two_line_frame( + first_start: DVec3, + first_end: DVec3, + second_start: DVec3, + second_end: DVec3, + arc_point: DVec3, +) -> Option<(DVec3, f64, f64)> { + let first_direction = first_end - first_start; + let second_direction = second_end - second_start; + let (t, _) = line_line( + [first_start.x, first_start.y], + [first_direction.x, first_direction.y], + [second_start.x, second_start.y], + [second_direction.x, second_direction.y], + )?; + let vertex = first_start + first_direction * t; + let target = (arc_point.y - vertex.y).atan2(arc_point.x - vertex.x); + let mut best: Option<(f64, f64, f64)> = None; + for first_sign in [1.0, -1.0] { + for second_sign in [1.0, -1.0] { + let first = first_direction * first_sign; + let second = second_direction * second_sign; + for (start, end) in [ + (first.y.atan2(first.x), second.y.atan2(second.x)), + (second.y.atan2(second.x), first.y.atan2(first.x)), + ] { + let sweep = (end - start).rem_euclid(std::f64::consts::TAU); + if sweep <= 1.0e-9 || sweep > std::f64::consts::PI { + continue; + } + let selected = (target - start).rem_euclid(std::f64::consts::TAU); + if selected <= sweep + 1.0e-9 && best.is_none_or(|known| sweep < known.2) { + best = Some((start, start + sweep, sweep)); + } + } + } + } + best.map(|(start, end, _)| (vertex, start, end)) +} + +fn angular_preview(vertex: DVec3, first: DVec3, second: DVec3, arc_point: DVec3) -> Vec { + let Some((_, start, end)) = two_line_frame(vertex, first, vertex, second, arc_point) else { + return vec![vertex, first, nan(), vertex, second]; + }; + angular_preview_with_frame(vertex, first, second, arc_point, start, end) +} + +fn two_line_preview( + first_start: DVec3, + first_end: DVec3, + second_start: DVec3, + second_end: DVec3, + arc_point: DVec3, +) -> Vec { + let Some((vertex, start, end)) = two_line_frame( + first_start, + first_end, + second_start, + second_end, + arc_point, + ) else { + return vec![first_start, first_end, nan(), second_start, second_end]; + }; + angular_preview_with_frame(vertex, first_start, second_start, arc_point, start, end) +} + +fn angular_preview_with_frame( + vertex: DVec3, + first: DVec3, + second: DVec3, + arc_point: DVec3, + start: f64, + end: f64, +) -> Vec { + let radius = vertex.distance(arc_point); + let first_end = vertex + DVec3::new(start.cos(), start.sin(), 0.0) * radius; + let second_end = vertex + DVec3::new(end.cos(), end.sin(), 0.0) * radius; + let mut points = vec![first, first_end, nan(), second, second_end, nan()]; + points.extend( + cadkernel::geom2d::tessellate::arc( + [vertex.x, vertex.y], + radius, + start, + end, + vertex.z, + cadkernel::geom2d::tessellate::DEFAULT_SEGMENTS_PER_RADIAN, + ) + .into_iter() + .map(DVec3::from_array), + ); + points +} + +fn v3(point: DVec3) -> Vector3 { + Vector3::new(point.x, point.y, point.z) +} + +fn nan() -> DVec3 { + DVec3::splat(f64::NAN) +} + +fn preview_wire(points: Vec) -> WireModel { WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -172,11 +1142,11 @@ fn preview_wire(points: Vec) -> WireModel { render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), name: "dimangular_preview".to_string(), - points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(), + points: points.into_iter().map(|point| [point.x as f32, point.y as f32, point.z as f32]).collect(), points_low: Vec::new(), color: WireModel::CYAN, selected: false, @@ -194,38 +1164,4 @@ fn preview_wire(points: Vec) -> WireModel { } } -fn angular_preview(vertex: Vec3, first: Vec3, second: Vec3, arc_pt: Vec3) -> Vec { - let mut points = vec![ - vertex, - first, - Vec3::new(f32::NAN, f32::NAN, f32::NAN), - vertex, - second, - Vec3::new(f32::NAN, f32::NAN, f32::NAN), - ]; - let r = vertex.distance(arc_pt); - if r <= 1e-6 { - return points; - } - let a0 = (first.y - vertex.y).atan2(first.x - vertex.x); - let mut a1 = (second.y - vertex.y).atan2(second.x - vertex.x); - let mut delta = a1 - a0; - while delta <= 0.0 { - delta += std::f32::consts::TAU; - } - if delta > std::f32::consts::PI { - a1 -= std::f32::consts::TAU; - delta = a1 - a0; - } - let steps = 24; - for i in 0..=steps { - let t = i as f32 / steps as f32; - let a = a0 + delta * t; - points.push(vertex + Vec3::new(a.cos() * r, a.sin() * r, 0.0)); - } - points -} - - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DIMANGULAR"] }); // AngularDimensionCommand +inventory::submit!(crate::command::CommandRegistration { names: &["DIMANGULAR"] }); diff --git a/src/modules/annotate/arc_length_dim.rs b/src/modules/annotate/arc_length_dim.rs new file mode 100644 index 00000000..f0225c1c --- /dev/null +++ b/src/modules/annotate/arc_length_dim.rs @@ -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 { + 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 { + 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 { + 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> { + 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, + source_handle: Option, + text_override: Option, + awaiting_text: bool, + text_angle: Option, + 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 { + 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 { + 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 { + 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, 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"] }); diff --git a/src/modules/annotate/data_link.rs b/src/modules/annotate/data_link.rs index a2e20e5b..80032620 100644 --- a/src/modules/annotate/data_link.rs +++ b/src/modules/annotate/data_link.rs @@ -1,6 +1,12 @@ -// DATALINK command — manages data links to external spreadsheets. +// DATALINK command — manages persistent links to external tabular data. +use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::model::wire_model::WireModel; +use crate::t; +use acadrust::entities::Table; +use acadrust::EntityType; +use glam::DVec3; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/data_link.svg")); @@ -12,3 +18,113 @@ pub fn tool() -> ToolDef { event: ModuleEvent::Command("DATALINK".to_string()), } } + +pub struct DataLinkPlaceCommand { + table: Table, + plane: WorkingPlane, +} + +impl DataLinkPlaceCommand { + pub fn new(mut table: Table, path: &str) -> Self { + table.name = format!("__OPENCAD_LINK_PENDING__{path}"); + Self { + table, + plane: WorkingPlane::default(), + } + } + + fn preview(&self, point: DVec3) -> WireModel { + let point = self.plane.to_local(point); + let mut points = Vec::new(); + let mut x = 0.0; + for column in 0..=self.table.column_count() { + if column > 0 { + x += self.table.columns[column - 1].width; + } + points.push(self.plane.to_world(point + DVec3::X * x).as_vec3().to_array()); + points.push( + self.plane + .to_world(point + DVec3::new(x, -self.table.total_height(), 0.0)) + .as_vec3() + .to_array(), + ); + } + let mut y = 0.0; + for row in 0..=self.table.row_count() { + if row > 0 { + y -= self.table.rows[row - 1].height; + } + points.push(self.plane.to_world(point + DVec3::Y * y).as_vec3().to_array()); + points.push( + self.plane + .to_world(point + DVec3::new(self.table.total_width(), y, 0.0)) + .as_vec3() + .to_array(), + ); + } + 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: "data_link_preview".into(), + points, + points_low: Vec::new(), + color: WireModel::CYAN, + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + } + } +} + +impl CadCommand for DataLinkPlaceCommand { + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + + fn name(&self) -> &'static str { + "DATALINK" + } + + fn prompt(&self) -> String { + t!("DATALINK Specify insertion point:").into_owned() + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + let point = self.plane.to_local(point); + self.table.insertion_point = acadrust::types::Vector3::new(point.x, point.y, point.z); + CmdResult::CommitAndExit( + self.plane + .place_entity(EntityType::Table(self.table.clone())), + ) + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + Some(self.preview(point)) + } +} diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index f0a570d6..010434c7 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -1,11 +1,11 @@ -// DIMDIAMETER command — diameter dimension for circles and arcs. - use acadrust::entities::{Dimension, DimensionDiameter}; -use acadrust::types::Vector3; +use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; use glam::{DVec3, Vec3}; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{ + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, WorkingPlane, +}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -22,40 +22,43 @@ pub fn tool() -> ToolDef { } enum Step { - CenterPoint, - ArcPoint(DVec3), - TextPoint { center: DVec3, arc_pt: DVec3 }, + SelectObject, + DimLine(crate::scene::dimension_assoc::RadialSourceGeometry), } pub struct DiameterDimensionCommand { step: Step, - plane: WorkingPlane, - /// Optional text that replaces the measured value (None = measurement). text_override: Option, - /// True while the next typed line is captured as the text override. awaiting_text: bool, - /// Explicit text rotation in radians (None = follow the UCS/style). text_angle: Option, - /// True while the next typed value is captured as the text angle. awaiting_angle: bool, + picked_entity: Option, + source_handle: Option, } impl DiameterDimensionCommand { pub fn new() -> Self { Self { - step: Step::CenterPoint, - plane: WorkingPlane::default(), + step: Step::SelectObject, text_override: None, awaiting_text: false, text_angle: None, awaiting_angle: false, + picked_entity: None, + source_handle: None, + } + } + + fn editor_anchor(&self) -> DVec3 { + match self.step { + Step::SelectObject => DVec3::ZERO, + Step::DimLine(source) => dvec(source.point_at_angle(source.start_angle)), } } } impl CadCommand for DiameterDimensionCommand { - fn set_working_plane(&mut self, plane: WorkingPlane) { - self.plane = plane; + fn set_working_plane(&mut self, _plane: WorkingPlane) { } fn name(&self) -> &'static str { @@ -64,54 +67,58 @@ impl CadCommand for DiameterDimensionCommand { fn prompt(&self) -> String { if self.awaiting_text { - return t!("DIMDIAMETER Enter dimension text (blank = measured value):").into_owned(); + return t!("DIMDIAMETER Enter dimension text (blank = measured value):") + .into_owned(); } if self.awaiting_angle { return t!("DIMDIAMETER Specify text angle (degrees):").into_owned(); } match self.step { - Step::CenterPoint => t!("DIMDIAMETER Specify center point:").into_owned(), - Step::ArcPoint(_) => t!("DIMDIAMETER Specify point on circle:").into_owned(), - Step::TextPoint { .. } => { - t!("DIMDIAMETER Specify dimension line location [Text/Angle]:").into_owned() + Step::SelectObject => { + t!("DIMDIAMETER Select arc, circle, or polyline arc:").into_owned() + } + Step::DimLine(_) => { + t!("DIMDIAMETER Specify dimension line location [Mtext/Text/Angle]:") + .into_owned() } } } fn on_point(&mut self, pt: DVec3) -> CmdResult { match self.step { - Step::CenterPoint => { - self.step = Step::ArcPoint(pt); - CmdResult::NeedPoint - } - Step::ArcPoint(center) => { - self.step = Step::TextPoint { center, arc_pt: pt }; - CmdResult::NeedPoint - } - Step::TextPoint { center, arc_pt } => { - let center = self.plane.to_local(center); - let arc_pt = self.plane.to_local(arc_pt); - let pt = self.plane.to_local(pt); - let mut dim = DimensionDiameter::new(v3(center), v3(arc_pt)); - dim.base.definition_point = v3(arc_pt); + Step::SelectObject => CmdResult::NeedPoint, + Step::DimLine(source) => { + let source_plane = WorkingPlane::new( + DVec3::from_array(source.plane.origin), + DVec3::from_array(source.plane.x_axis), + DVec3::from_array(source.plane.y_axis), + ); + let chord = source_plane.to_local(dvec(source.chord_at(pt.to_array()))); + let far_chord = source_plane.to_local(dvec(source.opposite_chord_at(pt.to_array()))); + let pt = source_plane.to_local(pt); + let mut dim = DimensionDiameter::new(v3(chord), v3(far_chord)); dim.base.text_middle_point = v3(pt); dim.base.insertion_point = v3(pt); - dim.leader_length = arc_pt.distance(pt); + dim.base.text_user_positioned = true; + dim.leader_length = chord.distance(pt); dim.base.actual_measurement = dim.measurement(); - dim.base.user_text = self.text_override.clone(); - // An explicit text angle overrides the default rotation. + crate::entities::dimension::set_dimension_text_override( + &mut dim.base, + self.text_override.clone(), + ); if let Some(a) = self.text_angle { dim.base.text_rotation = a; } - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Dimension( - Dimension::Diameter(dim), - ))) + CmdResult::CommitDimension { + entity: source_plane + .place_entity(EntityType::Dimension(Dimension::Diameter(dim))), + association: DimensionAssociationInput::Infer(self.source_handle), + } } } } fn on_enter(&mut self) -> CmdResult { - // A bare Enter while entering override text accepts the measured value. if self.awaiting_text { self.awaiting_text = false; return CmdResult::NeedPoint; @@ -123,24 +130,37 @@ impl CadCommand for DiameterDimensionCommand { CmdResult::Cancel } + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + fn wants_text_input(&self) -> bool { true } fn point_step_accepts_keywords(&self) -> bool { - // While entering the override text or angle it is a value, not a point step. !self.awaiting_text && !self.awaiting_angle } fn wants_text_with_spaces(&self) -> bool { - // The override text may contain spaces. self.awaiting_text } + fn options(&self) -> Vec { + if matches!(self.step, Step::DimLine(_)) && !self.awaiting_text && !self.awaiting_angle { + vec![ + CmdOption::new("MText", "MTEXT"), + CmdOption::new("Text", "TEXT"), + CmdOption::new("Angle", "ANGLE"), + ] + } else { + Vec::new() + } + } + fn on_text_input(&mut self, text: &str) -> Option { if self.awaiting_text { let t = text.trim(); - // Blank (or the "<>" placeholder) keeps the measured value. self.text_override = if t.is_empty() || t == "<>" { None } else { @@ -151,7 +171,6 @@ impl CadCommand for DiameterDimensionCommand { } if self.awaiting_angle { let t = text.trim(); - // Blank clears any explicit angle (follow the default again). self.text_angle = if t.is_empty() { None } else { @@ -160,11 +179,19 @@ impl CadCommand for DiameterDimensionCommand { self.awaiting_angle = false; return Some(CmdResult::NeedPoint); } - match text.trim().to_uppercase().as_str() { - "T" | "TEXT" | "M" | "MTEXT" => { + if !matches!(self.step, Step::DimLine(_)) { + return None; + } + match text.trim().to_ascii_uppercase().as_str() { + "T" | "TEXT" => { self.awaiting_text = true; Some(CmdResult::NeedPoint) } + "M" | "MTEXT" => Some(CmdResult::SuspendForMTextInput { + pos: self.editor_anchor(), + initial: self.text_override.clone().unwrap_or_default(), + height: 2.5, + }), "A" | "ANGLE" => { self.awaiting_angle = true; Some(CmdResult::NeedPoint) @@ -173,14 +200,60 @@ impl CadCommand for DiameterDimensionCommand { } } + 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.radius.is_finite() || source.radius <= 1e-12 { + return CmdResult::NeedPoint; + } + self.source_handle = Some(handle); + self.step = Step::DimLine(source); + CmdResult::NeedPoint + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let pt = pt.as_vec3(); match self.step { - Step::CenterPoint => None, - Step::ArcPoint(center) => Some(preview_line(center.as_vec3(), pt)), - Step::TextPoint { center, arc_pt } => { - let far = center + (center - arc_pt); // opposite point on circle - Some(preview_line(far.as_vec3(), pt)) + Step::SelectObject => None, + Step::DimLine(source) => { + let chord = dvec(source.chord_at(pt.to_array())).as_vec3(); + let far_chord = dvec(source.opposite_chord_at(pt.to_array())).as_vec3(); + Some(preview_line(far_chord, chord, pt.as_vec3())) } } } @@ -190,10 +263,16 @@ fn v3(p: DVec3) -> Vector3 { Vector3::new(p.x, p.y, p.z) } -fn preview_line(a: Vec3, b: Vec3) -> WireModel { +fn dvec(point: Vector3) -> DVec3 { + DVec3::new(point.x, point.y, point.z) +} + +fn preview_line(far_chord: Vec3, chord: Vec3, text: Vec3) -> WireModel { + let separator = [f32::NAN; 3]; WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -203,11 +282,17 @@ fn preview_line(a: Vec3, b: Vec3) -> WireModel { render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), name: "dimdia_preview".into(), - points: vec![[a.x, a.y, a.z], [b.x, b.y, b.z]], + points: vec![ + far_chord.to_array(), + chord.to_array(), + separator, + chord.to_array(), + text.to_array(), + ], points_low: Vec::new(), color: WireModel::CYAN, selected: false, @@ -225,6 +310,4 @@ fn preview_line(a: Vec3, b: Vec3) -> WireModel { } } - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DIMDIAMETER"] }); // DiameterDimensionCommand +inventory::submit!(crate::command::CommandRegistration { names: &["DIMDIAMETER"] }); diff --git a/src/modules/annotate/dim_baseline.rs b/src/modules/annotate/dim_baseline.rs index b3871839..207e9c35 100644 --- a/src/modules/annotate/dim_baseline.rs +++ b/src/modules/annotate/dim_baseline.rs @@ -182,6 +182,7 @@ impl CadCommand for DimBaselineCommand { Some(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/annotate/dim_continue.rs b/src/modules/annotate/dim_continue.rs index d2371aac..b7eb336b 100644 --- a/src/modules/annotate/dim_continue.rs +++ b/src/modules/annotate/dim_continue.rs @@ -162,6 +162,7 @@ impl CadCommand for DimContinueCommand { Some(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/annotate/dimjogline.rs b/src/modules/annotate/dimjogline.rs index ebaaa8b4..168b631d 100644 --- a/src/modules/annotate/dimjogline.rs +++ b/src/modules/annotate/dimjogline.rs @@ -88,6 +88,7 @@ impl CadCommand for DimJogLineCommand { Some(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/annotate/dimtedit.rs b/src/modules/annotate/dimtedit.rs index e8421320..6c5a60b9 100644 --- a/src/modules/annotate/dimtedit.rs +++ b/src/modules/annotate/dimtedit.rs @@ -105,6 +105,7 @@ impl CadCommand for DimTeditCommand { Some(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/annotate/jogged_radius_dim.rs b/src/modules/annotate/jogged_radius_dim.rs new file mode 100644 index 00000000..fdfc6097 --- /dev/null +++ b/src/modules/annotate/jogged_radius_dim.rs @@ -0,0 +1,479 @@ +use acadrust::entities::{Dimension, DimensionLargeRadial}; +use acadrust::types::{Handle, Vector3}; +use acadrust::EntityType; +use glam::{DVec3, Vec3}; + +use crate::command::{ + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionAssociationSource, + WorkingPlane, +}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::creation_style::DimensionCreationDefaults; +use crate::scene::dimension_assoc::RadialSourceGeometry; +use crate::scene::model::wire_model::WireModel; +use crate::t; + +pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/dim_jog.svg")); + +pub fn tool() -> ToolDef { + ToolDef { + id: "DIMJOGGED", + label: "Jogged Radius", + icon: ICON, + event: ModuleEvent::Command("DIMJOGGED".to_string()), + } +} + +#[derive(Clone, Copy)] +enum Step { + SelectObject, + OverrideCenter(RadialSourceGeometry), + DimLine { + source: RadialSourceGeometry, + override_center: DVec3, + }, + Jog { + source: RadialSourceGeometry, + override_center: DVec3, + chord: DVec3, + text_position: DVec3, + }, +} + +pub struct JoggedRadiusDimensionCommand { + step: Step, + defaults: DimensionCreationDefaults, + editor_height: f64, + text_override: Option, + text_angle: Option, + awaiting_text: bool, + awaiting_angle: bool, + picked_entity: Option, + source_handle: Option, +} + +impl JoggedRadiusDimensionCommand { + pub fn new(defaults: DimensionCreationDefaults, annotation_multiplier: f64) -> Self { + 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, + editor_height, + text_override: None, + text_angle: None, + awaiting_text: false, + awaiting_angle: false, + picked_entity: None, + source_handle: None, + } + } + + fn editor_anchor(&self) -> DVec3 { + match self.step { + Step::SelectObject => DVec3::ZERO, + Step::OverrideCenter(source) => dvec(source.point_at_angle(source.start_angle)), + Step::DimLine { + override_center, .. + } + | Step::Jog { + override_center, .. + } => override_center, + } + } + + fn commit_dimension( + &self, + source: RadialSourceGeometry, + override_center_world: DVec3, + chord_world: DVec3, + text_world: DVec3, + jog_world: DVec3, + ) -> CmdResult { + let plane = source_plane(source); + let center = plane.to_local(dvec(source.center_world())); + let chord = plane.to_local(chord_world); + let override_center = plane.to_local(override_center_world); + let jog = project_jog(override_center, chord, plane.to_local(jog_world)); + let text_position = plane.to_local(text_world); + + let mut dimension = DimensionLargeRadial::default(); + dimension.definition_point = v3(center); + dimension.base.definition_point = v3(center); + dimension.chord_point = v3(chord); + dimension.override_center = v3(override_center); + dimension.jog_point = v3(jog); + dimension.jog_angle = self.defaults.jog_angle; + dimension.base.style_name.clone_from(&self.defaults.style_name); + dimension.base.text_middle_point = v3(text_position); + dimension.base.insertion_point = v3(text_position); + 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(vec![Some( + DimensionAssociationSource::explicit( + handle, + source.marker, + source.angle_at(chord_world.to_array()), + ), + )]) + }, + ); + + CmdResult::CommitDimension { + entity: plane.place_entity(EntityType::Dimension(Dimension::LargeRadial( + dimension, + ))), + association, + } + } +} + +impl CadCommand for JoggedRadiusDimensionCommand { + fn set_working_plane(&mut self, _plane: WorkingPlane) {} + + fn name(&self) -> &'static str { + "DIMJOGGED" + } + + fn prompt(&self) -> String { + if self.awaiting_text { + return t!("DIMJOGGED Enter dimension text (blank = measured value):").into_owned(); + } + if self.awaiting_angle { + return t!("DIMJOGGED Specify text angle (degrees):").into_owned(); + } + match self.step { + Step::SelectObject => { + t!("DIMJOGGED Select arc, circle, or polyline arc:").into_owned() + } + Step::OverrideCenter(_) => { + t!("DIMJOGGED Specify center location override:").into_owned() + } + Step::DimLine { .. } => t!( + "DIMJOGGED Specify dimension line location [Mtext/Text/Angle]:" + ) + .into_owned(), + Step::Jog { .. } => t!("DIMJOGGED Specify jog location:").into_owned(), + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::SelectObject => CmdResult::NeedPoint, + Step::OverrideCenter(source) => { + self.step = Step::DimLine { + source, + override_center: point, + }; + CmdResult::NeedPoint + } + Step::DimLine { + source, + override_center, + } => { + self.step = Step::Jog { + source, + override_center, + chord: dvec(source.chord_at(point.to_array())), + text_position: point, + }; + CmdResult::NeedPoint + } + Step::Jog { + source, + override_center, + chord, + text_position, + } => self.commit_dimension( + source, + override_center, + chord, + text_position, + point, + ), + } + } + + 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 { + if matches!(self.step, Step::DimLine { .. }) + && !self.awaiting_text + && !self.awaiting_angle + { + vec![ + CmdOption::new("MText", "MTEXT"), + CmdOption::new("Text", "TEXT"), + CmdOption::new("Angle", "ANGLE"), + ] + } else { + Vec::new() + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + 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); + } + if !matches!(self.step, Step::DimLine { .. }) { + return None; + } + match text.trim().to_ascii_uppercase().as_str() { + "T" | "TEXT" => { + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "M" | "MTEXT" => Some(CmdResult::SuspendForMTextInput { + pos: self.editor_anchor(), + initial: self.text_override.clone().unwrap_or_default(), + height: self.editor_height, + }), + "A" | "ANGLE" => { + self.awaiting_angle = true; + 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.radius.is_finite() || source.radius <= 1e-12 { + return CmdResult::NeedPoint; + } + self.source_handle = Some(handle); + self.step = Step::OverrideCenter(source); + CmdResult::NeedPoint + } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + match self.step { + Step::SelectObject => None, + Step::OverrideCenter(source) => Some(preview_wire(vec![ + dvec(source.center_world()).as_vec3(), + point.as_vec3(), + ])), + Step::DimLine { + source, + override_center, + } => { + let chord = dvec(source.chord_at(point.to_array())); + Some(preview_wire(vec![ + override_center.as_vec3(), + chord.as_vec3(), + Vec3::splat(f32::NAN), + chord.as_vec3(), + point.as_vec3(), + ])) + } + Step::Jog { + source, + override_center, + chord, + .. + } => { + let plane = source_plane(source); + let chord_local = plane.to_local(chord); + let override_local = plane.to_local(override_center); + let jog_local = project_jog(override_local, chord_local, plane.to_local(point)); + let (near, far) = jog_break( + chord_local, + jog_local, + override_local, + self.defaults.jog_angle, + ); + Some(preview_wire( + [chord_local, near, far, override_local] + .into_iter() + .map(|position| plane.to_world(position).as_vec3()) + .collect(), + )) + } + } + } +} + +fn source_plane(source: RadialSourceGeometry) -> WorkingPlane { + WorkingPlane::new( + DVec3::from_array(source.plane.origin), + DVec3::from_array(source.plane.x_axis), + DVec3::from_array(source.plane.y_axis), + ) +} + +fn project_jog(override_center: DVec3, chord: DVec3, point: DVec3) -> DVec3 { + let line = chord - override_center; + let length_squared = line.length_squared(); + if length_squared <= 1e-18 { + return override_center; + } + let factor = ((point - override_center).dot(line) / length_squared).clamp(0.0, 1.0); + override_center + line * factor +} + +fn jog_break( + chord: DVec3, + jog: DVec3, + override_center: DVec3, + jog_angle: f64, +) -> (DVec3, DVec3) { + let radial = (chord - override_center).normalize_or_zero(); + let (sin, cos) = jog_angle.sin_cos(); + let transverse = DVec3::new( + radial.x * cos - radial.y * sin, + radial.x * sin + radial.y * cos, + 0.0, + ) + .normalize_or_zero(); + let half = ((chord - override_center).length() * 0.04).max(1e-3); + let first = jog - transverse * half; + let second = jog + transverse * half; + if chord.distance_squared(first) <= chord.distance_squared(second) { + (first, second) + } else { + (second, first) + } +} + +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 preview_wire(points: Vec) -> WireModel { + 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: "dimjogged_preview".to_string(), + points: points.into_iter().map(|point| point.to_array()).collect(), + points_low: Vec::new(), + color: WireModel::CYAN, + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["DIMJOGGED", "DIMJOG"] +}); diff --git a/src/modules/annotate/leader_cmd.rs b/src/modules/annotate/leader_cmd.rs index 1e13d936..c8dfece7 100644 --- a/src/modules/annotate/leader_cmd.rs +++ b/src/modules/annotate/leader_cmd.rs @@ -302,6 +302,7 @@ fn preview_wire(pts: &[Vec3], arrow_size: f32) -> WireModel { WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index f2af91e0..c2f927c8 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -1,25 +1,17 @@ use acadrust::entities::{Dimension, DimensionLinear}; use acadrust::types::Vector3; use acadrust::EntityType; +use cadkernel::geom2d::{ + closest_point, Circle as KernelCircle, Curve, Line as KernelLine, +}; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdResult, DimensionAssociationInput, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use glam::DVec3; use crate::t; -/// Which axis a linear dimension measures along. -/// -/// Not the segment's own direction. A linear dimension reports the run or the -/// rise, and which of the two is wanted is said by where the dimension line is -/// put: pulled clear of the points above or below, the horizontal distance is -/// what is left to show; pulled out to the side, the vertical one. Taking the -/// answer from the two origins alone made every shallow tilt horizontal -/// forever, whatever the cursor did. (#669) -/// -/// "Clear of the points" is measured against their extent rather than their -/// midpoint: a dimension line dragged straight up from near one end of a long -/// pair is still above them, though it is a long way from the middle. +/// Select the measured axis from where the dimension line clears the points. fn measure_axis(first: DVec3, second: DVec3, def: DVec3) -> DVec3 { let outside = |value: f64, a: f64, b: f64| { let (low, high) = if a <= b { (a, b) } else { (b, a) }; @@ -57,6 +49,25 @@ enum Step { DimensionLine { first: DVec3, second: DVec3 }, } +#[derive(Clone, Copy)] +enum AxisMode { + Automatic, + Horizontal, + Vertical, + Rotated(f64), +} + +impl AxisMode { + fn axis(self, first: DVec3, second: DVec3, point: DVec3) -> DVec3 { + match self { + Self::Automatic => measure_axis(first, second, point), + Self::Horizontal => DVec3::X, + Self::Vertical => DVec3::Y, + Self::Rotated(angle) => DVec3::new(angle.cos(), angle.sin(), 0.0), + } + } +} + pub struct LinearDimensionCommand { step: Step, plane: WorkingPlane, @@ -68,6 +79,13 @@ pub struct LinearDimensionCommand { text_angle: Option, /// True while the next typed value is captured as the text angle. awaiting_angle: bool, + /// True while a rotation value for the Rotated option is being entered. + awaiting_rotation: bool, + axis_mode: AxisMode, + selecting_object: bool, + picked_entity: Option, + source_handle: Option, + mtext_override: bool, } impl LinearDimensionCommand { @@ -79,6 +97,12 @@ impl LinearDimensionCommand { awaiting_text: false, text_angle: None, awaiting_angle: false, + awaiting_rotation: false, + axis_mode: AxisMode::Automatic, + selecting_object: false, + picked_entity: None, + source_handle: None, + mtext_override: false, } } } @@ -94,18 +118,32 @@ impl CadCommand for LinearDimensionCommand { fn prompt(&self) -> String { if self.awaiting_text { - return t!("DIMLINEAR Enter dimension text (blank = measured value):").into_owned(); + return if self.mtext_override { + t!("DIMLINEAR Enter formatted dimension text (blank = measured value):") + .into_owned() + } else { + t!("DIMLINEAR Enter dimension text (blank = measured value):").into_owned() + }; } if self.awaiting_angle { return t!("DIMLINEAR Specify text angle (degrees):").into_owned(); } + if self.awaiting_rotation { + return t!("DIMLINEAR Specify dimension line angle (degrees):").into_owned(); + } + if self.selecting_object { + return t!("DIMLINEAR Select object to dimension:").into_owned(); + } match self.step { - Step::FirstPoint => t!("DIMLINEAR Specify first extension line origin:").into_owned(), + Step::FirstPoint => { + t!("DIMLINEAR Specify first extension line origin or press Enter to select object:") + .into_owned() + } Step::SecondPoint(_) => { - t!("DIMLINEAR Specify second extension line origin [Text/Angle]:").into_owned() + t!("DIMLINEAR Specify second extension line origin:").into_owned() } Step::DimensionLine { .. } => { - t!("DIMLINEAR Specify dimension line location [Text/Angle]:").into_owned() + t!("DIMLINEAR Specify dimension line location [Mtext/Text/Angle/Horizontal/Vertical/Rotated]:").into_owned() } } } @@ -117,6 +155,9 @@ impl CadCommand for LinearDimensionCommand { CmdResult::NeedPoint } Step::SecondPoint(first) => { + if pt.distance_squared(first) <= 1e-24 { + return CmdResult::NeedPoint; + } self.step = Step::DimensionLine { first, second: pt }; CmdResult::NeedPoint } @@ -125,21 +166,28 @@ impl CadCommand for LinearDimensionCommand { let second = self.plane.to_local(second); let pt = self.plane.to_local(pt); let mut dim = DimensionLinear::new(v3(first), v3(second)); - let axis = measure_axis(first, second, pt); + let axis = self.axis_mode.axis(first, second, pt); dim.rotation = axis.y.atan2(axis.x); - dim.definition_point = v3(pt); - dim.base.definition_point = v3(pt); + dim.set_offset(dimension_line_offset(second, pt, axis)); + dim.base.definition_point = dim.definition_point; dim.base.text_middle_point = v3(linear_text_pos(first, second, pt, axis)); dim.base.insertion_point = dim.base.text_middle_point; dim.base.actual_measurement = dim.measurement(); - dim.base.user_text = self.text_override.clone(); + crate::entities::dimension::set_dimension_text_override( + &mut dim.base, + self.text_override.clone(), + ); // An explicit text angle overrides the UCS-derived rotation. if let Some(a) = self.text_angle { dim.base.text_rotation = a; } - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Dimension( + let entity = self.plane.place_entity(EntityType::Dimension( Dimension::Linear(dim), - ))) + )); + CmdResult::CommitDimension { + entity, + association: DimensionAssociationInput::Infer(self.source_handle), + } } } } @@ -154,6 +202,14 @@ impl CadCommand for LinearDimensionCommand { self.awaiting_angle = false; return CmdResult::NeedPoint; } + if self.awaiting_rotation { + self.awaiting_rotation = false; + return CmdResult::NeedPoint; + } + if matches!(self.step, Step::FirstPoint) { + self.selecting_object = true; + return CmdResult::NeedPoint; + } CmdResult::Cancel } @@ -168,7 +224,7 @@ impl CadCommand for LinearDimensionCommand { fn point_step_accepts_keywords(&self) -> bool { // While typing the override text or angle, route input as a value, not // a point pick / keyword. - !self.awaiting_text && !self.awaiting_angle + !self.awaiting_text && !self.awaiting_angle && !self.awaiting_rotation } fn wants_text_with_spaces(&self) -> bool { @@ -199,8 +255,24 @@ impl CadCommand for LinearDimensionCommand { self.awaiting_angle = false; return Some(CmdResult::NeedPoint); } + if self.awaiting_rotation { + if let Some(angle) = crate::entities::common::parse_typed_angle(text.trim()) { + self.axis_mode = AxisMode::Rotated(angle); + } + self.awaiting_rotation = false; + return Some(CmdResult::NeedPoint); + } + if !matches!(self.step, Step::DimensionLine { .. }) { + return None; + } match text.trim().to_uppercase().as_str() { - "T" | "TEXT" | "M" | "MTEXT" => { + "T" | "TEXT" => { + self.mtext_override = false; + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "M" | "MTEXT" => { + self.mtext_override = true; self.awaiting_text = true; Some(CmdResult::NeedPoint) } @@ -208,10 +280,54 @@ impl CadCommand for LinearDimensionCommand { self.awaiting_angle = true; Some(CmdResult::NeedPoint) } + "H" | "HORIZONTAL" => { + self.axis_mode = AxisMode::Horizontal; + Some(CmdResult::NeedPoint) + } + "V" | "VERTICAL" => { + self.axis_mode = AxisMode::Vertical; + Some(CmdResult::NeedPoint) + } + "R" | "ROTATED" => { + self.awaiting_rotation = true; + Some(CmdResult::NeedPoint) + } _ => None, } } + fn needs_entity_pick(&self) -> bool { + self.selecting_object + } + + 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: acadrust::Handle, point: DVec3) -> CmdResult { + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some((first, second)) = dimension_source_points(&entity, point) else { + return CmdResult::NeedPoint; + }; + if first.distance_squared(second) <= 1e-24 { + return CmdResult::NeedPoint; + } + self.source_handle = Some(handle); + self.selecting_object = false; + self.step = Step::DimensionLine { first, second }; + CmdResult::NeedPoint + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { match self.step { Step::FirstPoint => None, @@ -220,7 +336,7 @@ impl CadCommand for LinearDimensionCommand { let first = self.plane.to_local(first); let second = self.plane.to_local(second); let pt = self.plane.to_local(pt); - let axis = measure_axis(first, second, pt); + let axis = self.axis_mode.axis(first, second, pt); let points = linear_dimension_preview(first, second, pt, axis) .into_iter() .map(|point| self.plane.to_world(point)) @@ -235,10 +351,122 @@ fn v3(pt: DVec3) -> Vector3 { Vector3::new(pt.x, pt.y, pt.z) } +fn dimension_line_offset(second: DVec3, point: DVec3, axis: DVec3) -> f64 { + let perpendicular = DVec3::new(-axis.y, axis.x, 0.0); + (point - second).dot(perpendicular) +} + +pub(crate) fn dimension_source_points( + entity: &EntityType, + click: DVec3, +) -> Option<(DVec3, DVec3)> { + let point = |p: Vector3| DVec3::new(p.x, p.y, p.z); + match entity { + EntityType::Line(line) => Some((point(line.start), point(line.end))), + EntityType::Arc(arc) => Some((point(arc.start_point_wcs()), point(arc.end_point_wcs()))), + EntityType::Circle(circle) => { + let click = crate::scene::view::transform::wcs_point_to_ocs( + (click.x, click.y, click.z), + (circle.normal.x, circle.normal.y, circle.normal.z), + ); + let curve = Curve::Circle(KernelCircle { + centre: [circle.center.x, circle.center.y], + radius: circle.radius, + }); + let first = closest_point(&curve, [click.0, click.1]).point; + let parameter = curve.parameter_at(first); + let second = curve.point_at((parameter + 0.5).rem_euclid(1.0)); + Some(( + ocs_point(first, circle.center.z, circle.normal), + ocs_point(second, circle.center.z, circle.normal), + )) + } + EntityType::LwPolyline(polyline) => nearest_planar_source( + polyline + .vertices + .iter() + .map(|vertex| [vertex.location.x, vertex.location.y]), + polyline.is_closed, + polyline.elevation, + polyline.normal, + click, + ), + EntityType::Polyline2D(polyline) => { + let vertices = crate::entities::polyline::drawn_vertices2d(polyline) + .unwrap_or_else(|| polyline.vertices.clone()); + nearest_planar_source( + vertices + .iter() + .map(|vertex| [vertex.location.x, vertex.location.y]), + polyline.is_closed(), + polyline.elevation, + polyline.normal, + click, + ) + } + _ => None, + } +} + +fn nearest_planar_source( + points: impl IntoIterator, + closed: bool, + elevation: f64, + normal: Vector3, + click: DVec3, +) -> Option<(DVec3, DVec3)> { + let click = crate::scene::view::transform::wcs_point_to_ocs( + (click.x, click.y, click.z), + (normal.x, normal.y, normal.z), + ); + let (first, second) = nearest_segment(points, closed, [click.0, click.1])?; + Some(( + ocs_point(first, elevation, normal), + ocs_point(second, elevation, normal), + )) +} + +fn nearest_segment( + points: impl IntoIterator, + closed: bool, + click: [f64; 2], +) -> Option<([f64; 2], [f64; 2])> { + let points: Vec<_> = points.into_iter().collect(); + if points.len() < 2 { + return None; + } + let count = if closed { points.len() } else { points.len() - 1 }; + (0..count) + .map(|index| { + let first = points[index]; + let second = points[(index + 1) % points.len()]; + let distance = closest_point( + &Curve::Line(KernelLine { + start: first, + end: second, + }), + click, + ) + .distance; + (distance, first, second) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, first, second)| (first, second)) +} + +fn ocs_point(point: [f64; 2], elevation: f64, normal: Vector3) -> DVec3 { + let point = crate::scene::view::transform::ocs_point_to_wcs( + (point[0], point[1], elevation), + (normal.x, normal.y, normal.z), + ); + DVec3::new(point.0, point.1, point.2) +} + fn preview_wire(points: Vec) -> WireModel { WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -289,7 +517,23 @@ fn dim_line_endpoints(first: DVec3, second: DVec3, def: DVec3, axis: DVec3) -> ( fn linear_dimension_preview(first: DVec3, second: DVec3, def: DVec3, axis: DVec3) -> Vec { let (d1, d2) = dim_line_endpoints(first, second, def, axis); let nan = DVec3::new(f64::NAN, f64::NAN, f64::NAN); - vec![first, d1, nan, second, d2, nan, d1, d2] + let arrow = 0.22; + let perp = DVec3::new(-axis.y, axis.x, 0.0); + let text = linear_text_pos(first, second, def, axis); + let half_width = ((second - first).length().log10().max(0.0) + 1.0) * 0.18; + let half_height = 0.16; + vec![ + first, d1, nan, second, d2, nan, d1, d2, nan, + d1, d1 + axis * arrow + perp * arrow * 0.45, nan, + d1, d1 + axis * arrow - perp * arrow * 0.45, nan, + d2, d2 - axis * arrow + perp * arrow * 0.45, nan, + d2, d2 - axis * arrow - perp * arrow * 0.45, nan, + text - axis * half_width - perp * half_height, + text + axis * half_width - perp * half_height, + text + axis * half_width + perp * half_height, + text - axis * half_width + perp * half_height, + text - axis * half_width - perp * half_height, + ] } fn linear_text_pos(first: DVec3, second: DVec3, def: DVec3, axis: DVec3) -> DVec3 { diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index 383a8d66..5526c6d3 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -1,9 +1,4 @@ -// MLEADER command -// -// Flow: -// 1. CollectPoints — click arrowhead, then bend points; Enter (≥2) to finish -// 2. AskText — wants_text_input; blank Enter = no text -// → commit single MultiLeader entity +// MLEADER: pick the arrow and elbow, then edit the text. use acadrust::entities::MultiLeader; use acadrust::types::Vector3; @@ -74,17 +69,34 @@ impl CadCommand for MLeaderCommand { if self.verts.is_empty() { t!("MLEADER Specify arrowhead point:").into_owned() } else { - t!( - "MLEADER Specify next point [%{count} pts — Enter to place text]:", - count = self.verts.len() - ) - .into_owned() + t!("MLEADER Specify landing point:").into_owned() } } fn on_point(&mut self, pt: DVec3) -> CmdResult { self.verts.push(pt); - CmdResult::NeedPoint + + if self.verts.len() < 2 { + return CmdResult::NeedPoint; + } + + let local: Vec = self + .verts + .iter() + .map(|point| self.plane.to_local(*point)) + .collect(); + + let ml = build_mleader( + "", + &local, + Mat4::IDENTITY, + self.style.as_ref(), + self.display_scale, + ); + + CmdResult::CommitAndEditText( + self.plane.place_entity(EntityType::MultiLeader(ml)), + ) } fn on_enter(&mut self) -> CmdResult { @@ -162,14 +174,14 @@ fn build_mleader( style: Option<&acadrust::objects::MultiLeaderStyle>, display_scale: f64, ) -> MultiLeader { - // Last vertex = content/text location; remaining = leader line points - let (leader_pts, content_pt) = verts.split_at(verts.len() - 1); - let content_pt = content_pt[0]; + // First click is the arrow; second click is the elbow. + let arrow_pt = verts[0]; + let elbow_pt = verts[1]; - let leader_v3: Vec = leader_pts.iter().map(|p| v3(*p)).collect(); - let content_v3 = v3(content_pt); + let arrow_v3 = v3(arrow_pt); + let elbow_v3 = v3(elbow_pt); - let mut ml = MultiLeader::with_text(text, content_v3, leader_v3); + let mut ml = MultiLeader::with_text(text, elbow_v3, vec![arrow_v3]); if let Some(style) = style { crate::scene::annotative::apply_mleader_style(&mut ml, style); } else { @@ -186,33 +198,41 @@ fn build_mleader( ml.context.text_height = ml.text_height * display_scale; ml.context.arrowhead_size = ml.arrowhead_size * display_scale; ml.context.landing_gap = landing_gap; - // "Horizontal" landing + text run along the active UCS X axis (identity = - // world), so the annotation reads square to the user's coordinate system. + // Align the landing and text with the active UCS X axis. let ux = ucs.transform_vector3(Vec3::X).normalize_or(Vec3::X); // Which side of the leader the text sits on, measured along the UCS X axis. - let last_leader = leader_pts.last().copied().unwrap_or(content_pt); - let to_right = (content_pt - last_leader).dot(ux.as_dvec3()) >= 0.0; + let to_right = (elbow_pt - arrow_pt).dot(ux.as_dvec3()) >= 0.0; let sign = if to_right { 1.0 } else { -1.0 }; let landing = ux * (sign as f32); + ml.context.text_attachment_point = + if to_right { + acadrust::entities::multileader::TextAttachmentPointType::Left + } else { + acadrust::entities::multileader::TextAttachmentPointType::Right + }; - // Text + landing read along the UCS X axis. text_direction is what the - // renderer consults first, so set both. ml.context.text_rotation = (ux.y as f64).atan2(ux.x as f64); ml.context.text_direction = Vector3::new(ux.x as f64, ux.y as f64, 0.0); if let Some(root) = ml.context.leader_roots.first_mut() { - // Leader ends at the clicked point; the landing runs from there toward - // the text along the UCS X axis. - root.direction = Vector3::new(landing.x as f64, landing.y as f64, 0.0); - root.connection_point = content_v3; + root.direction = + Vector3::new(landing.x as f64, landing.y as f64, 0.0); + + root.connection_point = elbow_v3; root.landing_distance = landing_distance; } - // Seed the text one landing-length past the leader end, on the side the - // user dragged toward, offset along the UCS X axis. + // Place text beyond the landing and gap. let off = landing * (landing_distance + landing_gap) as f32; - ml.context.text_location = - Vector3::new(content_v3.x + off.x as f64, content_v3.y + off.y as f64, content_v3.z); + + let text_location = Vector3::new( + elbow_v3.x + off.x as f64, + elbow_v3.y + off.y as f64, + elbow_v3.z, + ); + + ml.context.text_location = text_location; + ml.context.content_base_point = text_location; ml } @@ -229,6 +249,7 @@ fn preview_wire(pts: &[Vec3], arrow_size: f32) -> WireModel { WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/annotate/mleader_edit.rs b/src/modules/annotate/mleader_edit.rs index e3f19c23..fe472797 100644 --- a/src/modules/annotate/mleader_edit.rs +++ b/src/modules/annotate/mleader_edit.rs @@ -464,6 +464,7 @@ fn preview_wire(pts: &[Vec3]) -> WireModel { WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/annotate/mod.rs b/src/modules/annotate/mod.rs index cb6d65f1..8c48397f 100644 --- a/src/modules/annotate/mod.rs +++ b/src/modules/annotate/mod.rs @@ -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; @@ -15,6 +16,7 @@ pub mod dimspace; pub mod dimtedit; pub mod leader_cmd; pub mod linear_dim; +pub mod jogged_radius_dim; pub mod mleader_cmd; pub mod mleader_edit; pub mod mtext; @@ -98,11 +100,21 @@ 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, radius_dim::tool().icon, ), + ( + jogged_radius_dim::tool().id, + jogged_radius_dim::tool().label, + jogged_radius_dim::tool().icon, + ), ( diameter_dim::tool().id, diameter_dim::tool().label, diff --git a/src/modules/annotate/mtext.rs b/src/modules/annotate/mtext.rs index a6a6d6d6..6af69039 100644 --- a/src/modules/annotate/mtext.rs +++ b/src/modules/annotate/mtext.rs @@ -1,7 +1,11 @@ -use crate::command::{CadCommand, CmdResult}; +use acadrust::entities::mtext::AttachmentPoint; +use acadrust::types::Vector3; +use acadrust::MText; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, DynField, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; -use glam::DVec3; use crate::t; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/mtext.svg")); @@ -15,20 +19,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, + plane: WorkingPlane, height: f64, + style: String, + attachment: AttachmentPoint, + line_spacing: f64, + rotation: f64, + width: Option, + column_type: i16, + column_count: i32, + column_width: f64, + column_gutter: f64, } impl MTextCommand { - pub fn with_height(height: f64) -> Self { + pub fn with_defaults(height: f64, style: String) -> Self { Self { - step: Step::InsertPoint, - height, + step: Step::FirstCorner, + first: None, + plane: WorkingPlane::default(), + height: height.max(1e-6), + style, + attachment: AttachmentPoint::TopLeft, + line_spacing: 1.0, + rotation: 0.0, + width: None, + column_type: 0, + column_count: 2, + column_width: 0.0, + column_gutter: 0.0, + } + } + + fn resume_point_step(&mut self) { + self.step = if self.first.is_some() { + Step::OppositeCorner + } else { + Step::FirstCorner + }; + } + + fn point_options() -> Vec { + vec![ + CmdOption::new("Height", "HEIGHT"), + CmdOption::new("Justify", "JUSTIFY"), + CmdOption::new("Line spacing", "LINESPACING"), + CmdOption::new("Rotation", "ROTATION"), + CmdOption::new("Style", "STYLE"), + CmdOption::new("Width", "WIDTH"), + CmdOption::new("Columns", "COLUMNS"), + ] + } + + fn begin_option(&mut self, keyword: &str) -> bool { + self.step = match keyword { + "H" | "HEIGHT" => Step::Height, + "J" | "JUSTIFY" => Step::Justify, + "L" | "LINESPACING" | "LINE SPACING" => Step::LineSpacing, + "R" | "ROTATION" => Step::Rotation, + "S" | "STYLE" => Step::Style, + "W" | "WIDTH" => Step::Width, + "C" | "COLUMNS" => Step::ColumnMode, + _ => return false, + }; + true + } + + fn boundary_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] :").into_owned(), + Step::LineSpacing => crate::tf!("MTEXT Enter line spacing factor (0.25-4.00) <{}>:", self.line_spacing).into_owned(), + Step::Rotation => crate::tf!("MTEXT Specify rotation angle <{}>:", self.rotation.to_degrees()).into_owned(), + Step::Style => crate::tf!("MTEXT Enter text style <{}>:", self.style).into_owned(), + Step::Width => crate::tf!("MTEXT Specify boundary width <{}>:", self.width.unwrap_or(0.0)).into_owned(), + Step::ColumnMode => t!("MTEXT Columns [None / Static / Dynamic] :").into_owned(), + Step::ColumnCount => crate::tf!("MTEXT Enter column count <{}>:", self.column_count).into_owned(), + Step::ColumnWidth => crate::tf!("MTEXT Enter column width <{}>:", self.column_width).into_owned(), + Step::ColumnGutter => crate::tf!("MTEXT Enter column gutter <{}>:", self.column_gutter).into_owned(), } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - // Hand off to the in-place editor (toolbar + text area + live preview). - CmdResult::OpenMTextEditor { - pos: pt, - handle: None, - initial: String::new(), - height: self.height, + fn options(&self) -> Vec { + match self.step { + Step::FirstCorner | Step::OppositeCorner => Self::point_options(), + Step::Justify => ["TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"] + .into_iter() + .map(|value| CmdOption::new(value, value)) + .collect(), + Step::ColumnMode => vec![ + CmdOption::new("None", "NONE"), + CmdOption::new("Static", "STATIC"), + CmdOption::new("Dynamic", "DYNAMIC"), + ], + _ => Vec::new(), + } + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::FirstCorner | Step::OppositeCorner) + } + + fn wants_text_input(&self) -> bool { + true + } + + fn wants_text_with_spaces(&self) -> bool { + matches!(self.step, Step::Style) + } + + fn dyn_field(&self) -> DynField { + match self.step { + Step::Rotation => DynField::Angle, + Step::Height + | Step::LineSpacing + | Step::Width + | Step::ColumnCount + | Step::ColumnWidth + | Step::ColumnGutter => DynField::Scalar, + _ => DynField::Point, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let trimmed = text.trim(); + let upper = trimmed.to_ascii_uppercase(); + if matches!(self.step, Step::FirstCorner | Step::OppositeCorner) + && self.begin_option(&upper) + { + return Some(CmdResult::NeedPoint); + } + + match self.step { + Step::Height => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value <= 0.0 { + return None; + } + self.height = value; + self.resume_point_step(); + } + Step::Justify => { + self.attachment = match upper.as_str() { + "TL" => AttachmentPoint::TopLeft, + "TC" => AttachmentPoint::TopCenter, + "TR" => AttachmentPoint::TopRight, + "ML" => AttachmentPoint::MiddleLeft, + "MC" => AttachmentPoint::MiddleCenter, + "MR" => AttachmentPoint::MiddleRight, + "BL" => AttachmentPoint::BottomLeft, + "BC" => AttachmentPoint::BottomCenter, + "BR" => AttachmentPoint::BottomRight, + _ => return None, + }; + self.resume_point_step(); + } + Step::LineSpacing => { + let value = trimmed.replace(',', ".").parse::().ok()?; + if !(0.25..=4.0).contains(&value) { + return None; + } + self.line_spacing = value; + self.resume_point_step(); + } + Step::Rotation => { + self.rotation = crate::entities::common::parse_typed_direction(trimmed)?; + self.resume_point_step(); + } + Step::Style => { + if trimmed.is_empty() { + return None; + } + self.style = trimmed.to_string(); + self.resume_point_step(); + } + Step::Width => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value < 0.0 { + return None; + } + self.width = Some(value); + self.resume_point_step(); + } + Step::ColumnMode => { + self.column_type = match upper.as_str() { + "N" | "NONE" => 0, + "S" | "STATIC" => 1, + "D" | "DYNAMIC" => 2, + _ => return None, + }; + if self.column_type == 0 { + self.resume_point_step(); + } else { + self.step = Step::ColumnCount; + } + } + Step::ColumnCount => { + let value = trimmed.parse::().ok()?; + if !(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 { + fn on_mouse_move(&mut self, _point: DVec3) -> Option { None } } - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] }); // MTextCommand +inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] }); diff --git a/src/modules/annotate/ordinate_dim.rs b/src/modules/annotate/ordinate_dim.rs index 331677a3..4ac1d8a1 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -1,19 +1,11 @@ -// DIMORDINATE command — ordinate (datum) dimension. -// -// Measures the X or Y distance from the UCS origin (datum) to a feature point. -// The user picks: -// 1. The feature location. -// 2. The leader endpoint (where the annotation line ends). -// -// If the leader moves mainly in Y → X-type ordinate (shows X coordinate). -// If the leader moves mainly in X → Y-type ordinate (shows Y coordinate). - use acadrust::entities::{Dimension, DimensionOrdinate}; use acadrust::types::Vector3; use acadrust::EntityType; use glam::{DVec3, Vec3}; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{ + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, WorkingPlane, +}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -32,9 +24,21 @@ enum Step { LeaderEndpoint { feature: DVec3 }, } +#[derive(Clone, Copy)] +enum DatumMode { + Automatic, + X, + Y, +} + pub struct OrdinateDimCommand { step: Step, plane: WorkingPlane, + datum_mode: DatumMode, + text_override: Option, + awaiting_text: bool, + text_angle: Option, + awaiting_angle: bool, } impl OrdinateDimCommand { @@ -42,6 +46,18 @@ impl OrdinateDimCommand { Self { step: Step::FeaturePoint, plane: WorkingPlane::default(), + datum_mode: DatumMode::Automatic, + text_override: None, + awaiting_text: false, + text_angle: None, + awaiting_angle: false, + } + } + + fn editor_anchor(&self) -> DVec3 { + match self.step { + Step::FeaturePoint => DVec3::ZERO, + Step::LeaderEndpoint { feature } => feature, } } } @@ -56,9 +72,19 @@ impl CadCommand for OrdinateDimCommand { } fn prompt(&self) -> String { + if self.awaiting_text { + return t!("DIMORDINATE Enter dimension text (blank = measured value):") + .into_owned(); + } + if self.awaiting_angle { + return t!("DIMORDINATE Specify text angle (degrees):").into_owned(); + } match self.step { Step::FeaturePoint => t!("DIMORDINATE Specify feature location:").into_owned(), - Step::LeaderEndpoint { .. } => t!("DIMORDINATE Specify leader endpoint:").into_owned(), + Step::LeaderEndpoint { .. } => t!( + "DIMORDINATE Specify leader endpoint [Xdatum/Ydatum/Mtext/Text/Angle]:" + ) + .into_owned(), } } @@ -71,26 +97,138 @@ impl CadCommand for OrdinateDimCommand { Step::LeaderEndpoint { feature } => { let feature = self.plane.to_local(feature); let pt = self.plane.to_local(pt); - let is_x = is_x_type(feature, pt); - let elbow = ordinate_elbow(feature, pt, is_x); + let is_x = match self.datum_mode { + DatumMode::Automatic => is_x_type(feature, pt), + DatumMode::X => true, + DatumMode::Y => false, + }; let mut dim = DimensionOrdinate::new(v3(feature), v3(pt), is_x); - // The leader is an orthogonal L from the feature to the - // endpoint; store its elbow as the definition point. Without it - // the renderer draws feature → (0,0,0) → endpoint, kinking the - // leader through the world origin. The old code also worked in - // the wrong (XZ) plane, dropping the Y coordinate. (#150) - dim.definition_point = v3(elbow); - dim.base.definition_point = v3(elbow); - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Dimension( - Dimension::Ordinate(dim), - ))) + crate::entities::dimension::set_dimension_text_override( + &mut dim.base, + self.text_override.clone(), + ); + if let Some(angle) = self.text_angle { + dim.base.text_rotation = angle; + } + dim.refresh_measurement(); + CmdResult::CommitDimension { + entity: self.plane.place_entity(EntityType::Dimension( + Dimension::Ordinate(dim), + )), + association: DimensionAssociationInput::Infer(None), + } } } } 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 { + if matches!(self.step, Step::LeaderEndpoint { .. }) + && !self.awaiting_text + && !self.awaiting_angle + { + vec![ + CmdOption::new("Xdatum", "XDATUM"), + CmdOption::new("Ydatum", "YDATUM"), + CmdOption::new("MText", "MTEXT"), + CmdOption::new("Text", "TEXT"), + CmdOption::new("Angle", "ANGLE"), + ] + } else { + Vec::new() + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + 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); + } + if !matches!(self.step, Step::LeaderEndpoint { .. }) { + return None; + } + match text.trim().to_ascii_uppercase().as_str() { + "X" | "XDATUM" => { + self.datum_mode = DatumMode::X; + Some(CmdResult::NeedPoint) + } + "Y" | "YDATUM" => { + self.datum_mode = DatumMode::Y; + Some(CmdResult::NeedPoint) + } + "T" | "TEXT" => { + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "M" | "MTEXT" => Some(CmdResult::SuspendForMTextInput { + pos: self.editor_anchor(), + initial: self.text_override.clone().unwrap_or_default(), + height: 2.5, + }), + "A" | "ANGLE" => { + self.awaiting_angle = true; + 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 on_mouse_move(&mut self, pt: DVec3) -> Option { let feature = match self.step { Step::LeaderEndpoint { feature } => feature, @@ -98,17 +236,23 @@ impl CadCommand for OrdinateDimCommand { }; let feature = self.plane.to_local(feature); let pt = self.plane.to_local(pt); - let is_x = is_x_type(feature, pt); - let elbow = ordinate_elbow(feature, pt, is_x); - let feature = self.plane.to_world(feature); - let elbow = self.plane.to_world(elbow); - let pt = self.plane.to_world(pt); - // Screen-only rubber band: downcast to f32 at the preview boundary. - Some(preview_wire(vec![ - feature.as_vec3(), - elbow.as_vec3(), - pt.as_vec3(), - ])) + let is_x = match self.datum_mode { + DatumMode::Automatic => is_x_type(feature, pt), + DatumMode::X => true, + DatumMode::Y => false, + }; + let dim = DimensionOrdinate::new(v3(feature), v3(pt), is_x); + let points = dim.leader_polyline(0.44, 0.0, None); + Some(preview_wire( + points + .into_iter() + .map(|point| { + self.plane + .to_world(DVec3::new(point.x, point.y, point.z)) + .as_vec3() + }) + .collect(), + )) } } @@ -116,29 +260,17 @@ fn v3(p: DVec3) -> Vector3 { Vector3::new(p.x, p.y, p.z) } -/// X-datum (labels the feature's X coordinate) when the leader runs more -/// vertically than horizontally; Y-datum otherwise. Mirrors the placement -/// decision so the preview and the committed entity agree. fn is_x_type(feature: DVec3, leader: DVec3) -> bool { let dx = (leader.x - feature.x).abs(); let dy = (leader.y - feature.y).abs(); - dy >= dx -} - -/// Orthogonal elbow of the ordinate leader: an X-datum runs along Y from the -/// feature then jogs across in X; a Y-datum runs along X then jogs in Y. -fn ordinate_elbow(feature: DVec3, leader: DVec3, is_x: bool) -> DVec3 { - if is_x { - DVec3::new(feature.x, leader.y, feature.z) - } else { - DVec3::new(leader.x, feature.y, feature.z) - } + dy > dx } fn preview_wire(points: Vec) -> WireModel { WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -148,9 +280,9 @@ fn preview_wire(points: Vec) -> WireModel { render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), name: "dimordinate_preview".into(), points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(), points_low: Vec::new(), diff --git a/src/modules/annotate/qdim.rs b/src/modules/annotate/qdim.rs index 4c1596a1..151cfd4a 100644 --- a/src/modules/annotate/qdim.rs +++ b/src/modules/annotate/qdim.rs @@ -95,6 +95,7 @@ impl CadCommand for QdimCommand { Some(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index e91ca64e..62a83348 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -1,12 +1,14 @@ use acadrust::entities::{Dimension, DimensionRadius}; -use acadrust::types::Vector3; +use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{ + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, WorkingPlane, +}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; -use glam::{DVec3, Vec3}; use crate::t; +use glam::{DVec3, Vec3}; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/dim_radius.svg")); @@ -20,40 +22,43 @@ pub fn tool() -> ToolDef { } enum Step { - CenterPoint, - RadiusPoint(DVec3), - TextPoint { center: DVec3, point: DVec3 }, + SelectObject, + DimLine(crate::scene::dimension_assoc::RadialSourceGeometry), } pub struct RadiusDimensionCommand { step: Step, - plane: WorkingPlane, - /// Optional text that replaces the measured value (None = measurement). text_override: Option, - /// True while the next typed line is captured as the text override. awaiting_text: bool, - /// Explicit text rotation in radians (None = follow the UCS/style). text_angle: Option, - /// True while the next typed value is captured as the text angle. awaiting_angle: bool, + picked_entity: Option, + source_handle: Option, } impl RadiusDimensionCommand { pub fn new() -> Self { Self { - step: Step::CenterPoint, - plane: WorkingPlane::default(), + step: Step::SelectObject, text_override: None, awaiting_text: false, text_angle: None, awaiting_angle: false, + picked_entity: None, + source_handle: None, + } + } + + fn editor_anchor(&self) -> DVec3 { + match self.step { + Step::SelectObject => DVec3::ZERO, + Step::DimLine(source) => dvec(source.point_at_angle(source.start_angle)), } } } impl CadCommand for RadiusDimensionCommand { - fn set_working_plane(&mut self, plane: WorkingPlane) { - self.plane = plane; + fn set_working_plane(&mut self, _plane: WorkingPlane) { } fn name(&self) -> &'static str { @@ -68,48 +73,50 @@ impl CadCommand for RadiusDimensionCommand { return t!("DIMRADIUS Specify text angle (degrees):").into_owned(); } match self.step { - Step::CenterPoint => t!("DIMRADIUS Specify center point:").into_owned(), - Step::RadiusPoint(_) => t!("DIMRADIUS Specify radius point:").into_owned(), - Step::TextPoint { .. } => { - t!("DIMRADIUS Specify dimension line location [Text/Angle]:").into_owned() + Step::SelectObject => t!("DIMRADIUS Select arc, circle, or polyline arc:").into_owned(), + Step::DimLine(_) => { + t!("DIMRADIUS Specify dimension line location [Mtext/Text/Angle]:").into_owned() } } } fn on_point(&mut self, pt: DVec3) -> CmdResult { match self.step { - Step::CenterPoint => { - self.step = Step::RadiusPoint(pt); - CmdResult::NeedPoint - } - Step::RadiusPoint(center) => { - self.step = Step::TextPoint { center, point: pt }; - CmdResult::NeedPoint - } - Step::TextPoint { center, point } => { - let center = self.plane.to_local(center); - let point = self.plane.to_local(point); - let pt = self.plane.to_local(pt); + Step::SelectObject => CmdResult::NeedPoint, + Step::DimLine(source) => { + let source_plane = WorkingPlane::new( + DVec3::from_array(source.plane.origin), + DVec3::from_array(source.plane.x_axis), + DVec3::from_array(source.plane.y_axis), + ); + let center_world = dvec(source.center_world()); + let point_world = dvec(source.chord_at(pt.to_array())); + let center = source_plane.to_local(center_world); + let point = source_plane.to_local(point_world); + let pt = source_plane.to_local(pt); let mut dim = DimensionRadius::new(v3(center), v3(point)); dim.base.definition_point = v3(point); dim.base.text_middle_point = v3(pt); dim.base.insertion_point = v3(pt); + dim.base.text_user_positioned = true; dim.leader_length = point.distance(pt); dim.base.actual_measurement = dim.measurement(); - dim.base.user_text = self.text_override.clone(); - // An explicit text angle overrides the default rotation. + crate::entities::dimension::set_dimension_text_override( + &mut dim.base, + self.text_override.clone(), + ); if let Some(a) = self.text_angle { dim.base.text_rotation = a; } - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Dimension( - Dimension::Radius(dim), - ))) + CmdResult::CommitDimension { + entity: source_plane.place_entity(EntityType::Dimension(Dimension::Radius(dim))), + association: DimensionAssociationInput::Infer(self.source_handle), + } } } } fn on_enter(&mut self) -> CmdResult { - // A bare Enter while entering override text accepts the measured value. if self.awaiting_text { self.awaiting_text = false; return CmdResult::NeedPoint; @@ -130,19 +137,28 @@ impl CadCommand for RadiusDimensionCommand { } fn point_step_accepts_keywords(&self) -> bool { - // While entering the override text or angle it is a value, not a point step. !self.awaiting_text && !self.awaiting_angle } fn wants_text_with_spaces(&self) -> bool { - // The override text may contain spaces. self.awaiting_text } + fn options(&self) -> Vec { + if matches!(self.step, Step::DimLine(_)) && !self.awaiting_text && !self.awaiting_angle { + vec![ + CmdOption::new("MText", "MTEXT"), + CmdOption::new("Text", "TEXT"), + CmdOption::new("Angle", "ANGLE"), + ] + } else { + Vec::new() + } + } + fn on_text_input(&mut self, text: &str) -> Option { if self.awaiting_text { let t = text.trim(); - // Blank (or the "<>" placeholder) keeps the measured value. self.text_override = if t.is_empty() || t == "<>" { None } else { @@ -153,7 +169,6 @@ impl CadCommand for RadiusDimensionCommand { } if self.awaiting_angle { let t = text.trim(); - // Blank clears any explicit angle (follow the UCS/style again). self.text_angle = if t.is_empty() { None } else { @@ -162,11 +177,21 @@ impl CadCommand for RadiusDimensionCommand { self.awaiting_angle = false; return Some(CmdResult::NeedPoint); } - match text.trim().to_uppercase().as_str() { - "T" | "TEXT" | "M" | "MTEXT" => { + if !matches!(self.step, Step::DimLine(_)) { + return None; + } + match text.trim().to_ascii_uppercase().as_str() { + "T" | "TEXT" => { self.awaiting_text = true; Some(CmdResult::NeedPoint) } + "M" | "MTEXT" => { + Some(CmdResult::SuspendForMTextInput { + pos: self.editor_anchor(), + initial: self.text_override.clone().unwrap_or_default(), + height: 2.5, + }) + } "A" | "ANGLE" => { self.awaiting_angle = true; Some(CmdResult::NeedPoint) @@ -175,18 +200,67 @@ impl CadCommand for RadiusDimensionCommand { } } + 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.radius.is_finite() || source.radius <= 1e-12 { + return CmdResult::NeedPoint; + } + self.source_handle = Some(handle); + self.step = Step::DimLine(source); + CmdResult::NeedPoint + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let pt = pt.as_vec3(); match self.step { - Step::CenterPoint => None, - Step::RadiusPoint(center) => Some(preview_wire(vec![center.as_vec3(), pt])), - Step::TextPoint { center, point } => Some(preview_wire(vec![ - center.as_vec3(), - point.as_vec3(), - Vec3::new(f32::NAN, f32::NAN, f32::NAN), - point.as_vec3(), - pt, - ])), + Step::SelectObject => None, + Step::DimLine(source) => { + let center = dvec(source.center_world()).as_vec3(); + let point = dvec(source.chord_at(pt.to_array())).as_vec3(); + Some(preview_wire(vec![ + center, + point, + Vec3::new(f32::NAN, f32::NAN, f32::NAN), + point, + pt.as_vec3(), + ])) + } } } } @@ -195,10 +269,15 @@ fn v3(pt: DVec3) -> Vector3 { Vector3::new(pt.x, pt.y, pt.z) } +fn dvec(point: Vector3) -> DVec3 { + DVec3::new(point.x, point.y, point.z) +} + fn preview_wire(points: Vec) -> WireModel { WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -208,9 +287,9 @@ fn preview_wire(points: Vec) -> WireModel { render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), name: "dimradius_preview".to_string(), points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(), points_low: Vec::new(), @@ -230,6 +309,4 @@ fn preview_wire(points: Vec) -> WireModel { } } - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DIMRADIUS"] }); // RadiusDimensionCommand +inventory::submit!(crate::command::CommandRegistration { names: &["DIMRADIUS"] }); diff --git a/src/modules/annotate/table_cmd.rs b/src/modules/annotate/table_cmd.rs index c243e2b6..0e597cc9 100644 --- a/src/modules/annotate/table_cmd.rs +++ b/src/modules/annotate/table_cmd.rs @@ -1,16 +1,14 @@ -// TABLE command — create an empty table entity. +// TABLE command — create a styled empty table. // -// Workflow: -// 1. Text: Enter number of columns (default 3) -// 2. Text: Enter number of rows (default 4, includes header row) -// 3. Point: Click insertion point -// -// Creates a Table entity with uniform row height (0.5) and column width (2.0). +// Title/header rows come from the selected table style and are not counted as +// data rows. The command remembers its previous settings and supports either a +// fixed insertion point or a two-corner sizing window. use acadrust::entities::TableBuilder; use acadrust::types::Vector3; use acadrust::EntityType; use glam::DVec3; +use std::sync::{Mutex, OnceLock}; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -29,22 +27,80 @@ pub fn tool() -> ToolDef { } const DEFAULT_COLS: usize = 3; -const DEFAULT_ROWS: usize = 4; -const COL_WIDTH: f64 = 2.0; -const ROW_HEIGHT: f64 = 0.5; +const DEFAULT_DATA_ROWS: usize = 4; +const DEFAULT_COL_WIDTH: f64 = 2.0; +const DEFAULT_ROW_HEIGHT: f64 = 0.5; +#[derive(Clone, Copy)] +enum InsertionMode { + Point, + Window, +} + +#[derive(Clone, Copy)] +struct TableDefaults { + columns: usize, + data_rows: usize, + column_width: f64, + row_height: f64, + insertion_mode: InsertionMode, +} + +impl Default for TableDefaults { + fn default() -> Self { + Self { + columns: DEFAULT_COLS, + data_rows: DEFAULT_DATA_ROWS, + column_width: DEFAULT_COL_WIDTH, + row_height: DEFAULT_ROW_HEIGHT, + insertion_mode: InsertionMode::Point, + } + } +} + +fn saved_defaults() -> &'static Mutex { + static DEFAULTS: OnceLock> = OnceLock::new(); + DEFAULTS.get_or_init(|| Mutex::new(TableDefaults::default())) +} + +#[derive(Clone, Copy)] enum Step { Columns, - Rows { cols: usize }, - Insertion { cols: usize, rows: usize }, + DataRows { columns: usize }, + ColumnWidth { columns: usize, data_rows: usize }, + RowHeight { + columns: usize, + data_rows: usize, + column_width: f64, + }, + InsertionMode { + columns: usize, + data_rows: usize, + column_width: f64, + row_height: f64, + }, + Insertion { + columns: usize, + data_rows: usize, + column_width: f64, + row_height: f64, + }, + WindowFirst { columns: usize, data_rows: usize }, + WindowSecond { + columns: usize, + data_rows: usize, + first: DVec3, + }, } pub struct TableCommand { step: Step, style_handle: Option, - column_width: f64, - row_height: f64, + suggested_column_width: f64, + suggested_row_height: f64, preview_scale: f64, + title_row: bool, + header_row: bool, plane: WorkingPlane, } @@ -53,9 +109,11 @@ impl TableCommand { Self { step: Step::Columns, style_handle: None, - column_width: COL_WIDTH, - row_height: ROW_HEIGHT, + suggested_column_width: DEFAULT_COL_WIDTH, + suggested_row_height: DEFAULT_ROW_HEIGHT, preview_scale: 1.0, + title_row: true, + header_row: true, plane: WorkingPlane::default(), } } @@ -71,21 +129,122 @@ impl TableCommand { .max(style.header_row_style.text_height) .max(style.title_row_style.text_height) .max(1.0e-6); - let row_height = text_height * 1.5 + style.vertical_margin * 2.0; - let column_width = text_height * 8.0 + style.horizontal_margin * 2.0; Self { step: Step::Columns, style_handle: Some(style_handle), - column_width, - row_height, + suggested_column_width: text_height * 8.0 + style.horizontal_margin * 2.0, + suggested_row_height: text_height * 1.5 + style.vertical_margin * 2.0, preview_scale: if style.annotative { annotation_multiplier } else { 1.0 }, + title_row: !style.title_suppressed, + header_row: !style.header_suppressed, plane: WorkingPlane::default(), } } + + fn defaults(&self) -> TableDefaults { + let mut defaults = saved_defaults().lock().map(|value| *value).unwrap_or_default(); + if (defaults.column_width - DEFAULT_COL_WIDTH).abs() < 1.0e-9 { + defaults.column_width = self.suggested_column_width; + } + if (defaults.row_height - DEFAULT_ROW_HEIGHT).abs() < 1.0e-9 { + defaults.row_height = self.suggested_row_height; + } + defaults + } + + fn total_rows(&self, data_rows: usize) -> usize { + data_rows + usize::from(self.title_row) + usize::from(self.header_row) + } + + fn build_table( + &self, + point: DVec3, + rows: usize, + columns: usize, + column_width: f64, + row_height: f64, + ) -> EntityType { + let point = self.plane.to_local(point); + let mut table = TableBuilder::new(rows, columns) + .at(Vector3::new(point.x, point.y, point.z)) + .row_height(row_height) + .column_width(column_width) + .build(); + table.table_style_handle = self.style_handle; + self.plane.place_entity(EntityType::Table(table)) + } + + fn preview_grid( + &self, + point: DVec3, + rows: usize, + columns: usize, + column_width: f64, + row_height: f64, + scale: f64, + ) -> WireModel { + let point = self.plane.to_local(point); + let width = columns as f64 * column_width * scale; + let height = rows as f64 * row_height * scale; + let mut points = Vec::with_capacity((rows + columns + 2) * 2); + for column in 0..=columns { + let x = column as f64 * column_width * scale; + points.push(self.plane.to_world(point + DVec3::X * x).as_vec3().to_array()); + points.push( + self.plane + .to_world(point + DVec3::new(x, -height, 0.0)) + .as_vec3() + .to_array(), + ); + } + for row in 0..=rows { + let y = -(row as f64 * row_height * scale); + points.push(self.plane.to_world(point + DVec3::Y * y).as_vec3().to_array()); + points.push( + self.plane + .to_world(point + DVec3::new(width, y, 0.0)) + .as_vec3() + .to_array(), + ); + } + 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: "table_preview".into(), + points, + points_low: Vec::new(), + color: WireModel::CYAN, + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + } + } } impl CadCommand for TableCommand { @@ -98,146 +257,293 @@ impl CadCommand for TableCommand { } fn prompt(&self) -> String { + let defaults = self.defaults(); match &self.step { Step::Columns => t!( "TABLE Enter number of columns [%{cols}]:", - cols = DEFAULT_COLS + cols = defaults.columns ) .into_owned(), - Step::Rows { cols } => t!( - "TABLE Enter number of rows (incl. header) [%{rows}] (%{cols} cols):", - rows = DEFAULT_ROWS, - cols = cols + Step::DataRows { columns } => t!( + "TABLE Enter number of data rows [%{rows}] (%{cols} cols):", + rows = defaults.data_rows, + cols = columns ) .into_owned(), - Step::Insertion { cols, rows } => t!( + Step::ColumnWidth { .. } => t!( + "TABLE Enter column width [%{width}]:", + width = format!("{:.4}", defaults.column_width) + ) + .into_owned(), + Step::RowHeight { .. } => t!( + "TABLE Enter row height [%{height}]:", + height = format!("{:.4}", defaults.row_height) + ) + .into_owned(), + Step::InsertionMode { .. } => t!( + "TABLE Insertion behavior [Point/Window] <%{mode}>:", + mode = match defaults.insertion_mode { + InsertionMode::Point => t!("Point"), + InsertionMode::Window => t!("Window"), + } + ) + .into_owned(), + Step::Insertion { columns, data_rows, .. } => t!( "TABLE Specify insertion point [%{cols}×%{rows}]:", - cols = cols, - rows = rows + cols = columns, + rows = self.total_rows(*data_rows) ) .into_owned(), + Step::WindowFirst { .. } => { + t!("%{n} Specify first corner:", n = "TABLE").into_owned() + } + Step::WindowSecond { .. } => { + t!("%{n} Specify opposite corner:", n = "TABLE").into_owned() + } } } fn wants_text_input(&self) -> bool { - !matches!(self.step, Step::Insertion { .. }) + matches!( + self.step, + Step::Columns + | Step::DataRows { .. } + | Step::ColumnWidth { .. } + | Step::RowHeight { .. } + | Step::InsertionMode { .. } + ) } fn on_text_input(&mut self, text: &str) -> Option { - let t = text.trim(); - match &self.step { + let input = text.trim(); + let defaults = self.defaults(); + match self.step { Step::Columns => { - let cols = if t.is_empty() { - DEFAULT_COLS + let columns = if input.is_empty() { + defaults.columns } else { - t.parse::().ok().filter(|&n| n >= 1)? + input.parse::().ok().filter(|value| *value > 0)? }; - self.step = Step::Rows { cols }; - Some(CmdResult::NeedPoint) + self.step = Step::DataRows { columns }; } - Step::Rows { cols } => { - let cols = *cols; - let rows = if t.is_empty() { - DEFAULT_ROWS + Step::DataRows { columns } => { + let data_rows = if input.is_empty() { + defaults.data_rows } else { - t.parse::().ok().filter(|&n| n >= 1)? + input.parse::().ok().filter(|value| *value > 0)? }; - self.step = Step::Insertion { cols, rows }; - Some(CmdResult::NeedPoint) + self.step = Step::ColumnWidth { columns, data_rows }; + } + Step::ColumnWidth { columns, data_rows } => { + let column_width = if input.is_empty() { + defaults.column_width + } else { + input.parse::().ok().filter(|value| *value > 0.0)? + }; + self.step = Step::RowHeight { + columns, + data_rows, + column_width, + }; + } + Step::RowHeight { columns, data_rows, column_width } => { + let row_height = if input.is_empty() { + defaults.row_height + } else { + input.parse::().ok().filter(|value| *value > 0.0)? + }; + self.step = Step::InsertionMode { + columns, + data_rows, + column_width, + row_height, + }; + } + Step::InsertionMode { columns, data_rows, column_width, row_height } => { + let mode = if input.is_empty() { + defaults.insertion_mode + } else if "POINT".starts_with(&input.to_ascii_uppercase()) { + InsertionMode::Point + } else if "WINDOW".starts_with(&input.to_ascii_uppercase()) { + InsertionMode::Window + } else { + return None; + }; + if let Ok(mut saved) = saved_defaults().lock() { + *saved = TableDefaults { + columns, + data_rows, + column_width, + row_height, + insertion_mode: mode, + }; + } + self.step = match mode { + InsertionMode::Point => Step::Insertion { + columns, + data_rows, + column_width, + row_height, + }, + InsertionMode::Window => Step::WindowFirst { columns, data_rows }, + }; + } + Step::Insertion { .. } | Step::WindowFirst { .. } | Step::WindowSecond { .. } => { + return None; } - Step::Insertion { .. } => None, } + Some(CmdResult::NeedPoint) } fn on_enter(&mut self) -> CmdResult { - match &self.step { - Step::Columns => { - // Accept default. - self.step = Step::Rows { cols: DEFAULT_COLS }; - CmdResult::NeedPoint - } - Step::Rows { cols } => { - let cols = *cols; - self.step = Step::Insertion { - cols, - rows: DEFAULT_ROWS, + self.on_text_input("") + .map_or(CmdResult::Cancel, |_| CmdResult::NeedPoint) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::Insertion { + columns, + data_rows, + column_width, + row_height, + } => CmdResult::CommitAndExit(self.build_table( + point, + self.total_rows(data_rows), + columns, + column_width, + row_height, + )), + Step::WindowFirst { columns, data_rows } => { + self.step = Step::WindowSecond { + columns, + data_rows, + first: point, }; CmdResult::NeedPoint } - Step::Insertion { .. } => CmdResult::Cancel, + Step::WindowSecond { columns, data_rows, first } => { + let first = self.plane.to_local(first); + let second = self.plane.to_local(point); + let rows = self.total_rows(data_rows); + let width = (second.x - first.x).abs().max(1.0e-6); + let height = (second.y - first.y).abs().max(1.0e-6); + let top_left = DVec3::new(first.x.min(second.x), first.y.max(second.y), first.z); + CmdResult::CommitAndExit(self.build_table( + self.plane.to_world(top_left), + rows, + columns, + width / columns as f64, + height / rows as f64, + )) + } + _ => CmdResult::NeedPoint, } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - if let Step::Insertion { cols, rows } = self.step { - let point = self.plane.to_local(pt); - let ins = Vector3::new(point.x, point.y, point.z); - let mut table = TableBuilder::new(rows, cols) - .at(ins) - .row_height(self.row_height) - .column_width(self.column_width) - .build(); - table.table_style_handle = self.style_handle; - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Table(table))) - } else { - CmdResult::NeedPoint - } - } - - fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if let Step::Insertion { cols, rows } = self.step { - // Preview: outline of the table bounding box. - let w = (cols as f32) * (self.column_width * self.preview_scale) as f32; - let h = (rows as f32) * (self.row_height * self.preview_scale) as f32; - let point = self.plane.to_local(pt); - let corners = [ + fn on_mouse_move(&mut self, point: DVec3) -> Option { + match self.step { + Step::Insertion { + columns, + data_rows, + column_width, + row_height, + } => Some(self.preview_grid( point, - point + DVec3::X * w as f64, - point + DVec3::new(w as f64, -(h as f64), 0.0), - point - DVec3::Y * h as f64, - ] - .map(|corner| self.plane.to_world(corner).as_vec3().to_array()); - let [p0, p1, p2, p3] = corners; - Some(WireModel { - point_marker: None, - taper_widths: 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: "table_preview".into(), - points: vec![ - p0, p1, p1, p2, p2, p3, p3, p0, - ], - points_low: Vec::new(), - color: WireModel::CYAN, - selected: false, - pattern_length: 0.0, - pattern: [0.0; 8], - line_weight_px: 1.0, - snap_pts: vec![], - tangent_geoms: vec![], - aci: 0, - key_vertices: vec![], - aabb: WireModel::UNBOUNDED_AABB, - plinegen: true, - fill_tris: vec![], - fill_tris_low: Vec::new(), - }) - } else { - None + self.total_rows(data_rows), + columns, + column_width, + row_height, + self.preview_scale, + )), + Step::WindowSecond { columns, data_rows, first } => { + let first = self.plane.to_local(first); + let second = self.plane.to_local(point); + let rows = self.total_rows(data_rows); + let width = (second.x - first.x).abs().max(1.0e-6); + let height = (second.y - first.y).abs().max(1.0e-6); + let top_left = DVec3::new(first.x.min(second.x), first.y.max(second.y), first.z); + Some(self.preview_grid( + self.plane.to_world(top_left), + rows, + columns, + width / columns as f64, + height / rows as f64, + 1.0, + )) + } + _ => None, } } } +inventory::submit!(crate::command::CommandRegistration { names: &["TABLE"] }); -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["TABLE"] }); // TableCommand +pub struct TableCellEditCommand { + handle: acadrust::Handle, + table: acadrust::entities::Table, + row: usize, + column: usize, +} + +impl TableCellEditCommand { + pub fn new( + handle: acadrust::Handle, + table: &acadrust::entities::Table, + row: usize, + column: usize, + ) -> Self { + Self { + handle, + table: table.clone(), + row, + column, + } + } +} + +impl CadCommand for TableCellEditCommand { + fn name(&self) -> &'static str { + "TABLE CELL" + } + + fn prompt(&self) -> String { + let current = self.table.cell_text(self.row, self.column).unwrap_or(""); + t!( + "TABLE CELL Enter text for [%{row},%{column}] <%{current}>:", + row = self.row, + column = self.column, + current = current + ) + .into_owned() + } + + fn wants_text_input(&self) -> bool { + true + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_text_input(&mut self, text: &str) -> Option { + use acadrust::entities::table::CellStateFlags; + let cell = self.table.cell_mut(self.row, self.column)?; + if cell + .state + .intersects(CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY) + { + return Some(CmdResult::Cancel); + } + cell.set_text(text); + self.table.block_record_handle = None; + Some(CmdResult::ReplaceMany( + vec![(self.handle, vec![EntityType::Table(self.table.clone())])], + Vec::new(), + )) + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} diff --git a/src/modules/annotate/text.rs b/src/modules/annotate/text.rs index c00ad981..90d815a6 100644 --- a/src/modules/annotate/text.rs +++ b/src/modules/annotate/text.rs @@ -1,7 +1,14 @@ -use crate::command::{CadCommand, CmdResult}; -use crate::modules::{IconKind, ModuleEvent, ToolDef}; -use crate::scene::model::wire_model::WireModel; +use acadrust::entities::{ + Text, TextHorizontalAlignment as HA, TextVerticalAlignment as VA, +}; +use acadrust::tables::TextStyle; +use acadrust::types::Vector3; use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::creation_style::TextCreationDefaults; +use crate::scene::model::wire_model::WireModel; use crate::t; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/text.svg")); @@ -15,57 +22,434 @@ pub fn tool() -> ToolDef { } } +#[derive(Clone, Copy, PartialEq, Eq)] enum Step { - InsertPoint, + Start, + Justification, + Style, + Height, + Rotation, + SecondPoint, } pub struct TextCommand { step: Step, + plane: WorkingPlane, + first_point: Option, + second_point: Option, + horizontal: HA, + vertical: VA, + style_name: String, + styles: Vec, height: f64, + rotation: f64, + width_factor: f64, + oblique_angle: f64, + fixed_height: bool, + annotative: bool, + annotation_multiplier: f64, + last_display_height: Option, + last_entity: Option, } impl TextCommand { - pub fn with_height(height: f64) -> Self { - Self { - step: Step::InsertPoint, - height, + pub fn with_defaults( + defaults: TextCreationDefaults, + styles: Vec, + annotation_multiplier: f64, + ) -> Self { + let current_height = defaults.height; + let mut command = Self { + step: Step::Start, + plane: WorkingPlane::default(), + first_point: None, + second_point: None, + horizontal: HA::Left, + vertical: VA::Baseline, + style_name: defaults.style_name, + styles, + height: defaults.height, + rotation: 0.0, + width_factor: defaults.width_factor, + oblique_angle: defaults.oblique_angle, + fixed_height: false, + annotative: false, + annotation_multiplier: annotation_multiplier.max(1.0e-9), + last_display_height: None, + last_entity: None, + }; + let style = command.style_name.clone(); + command.select_style(&style); + if !command.fixed_height { + command.height = current_height; } + command + } + + fn select_style(&mut self, name: &str) -> bool { + let Some(style) = self + .styles + .iter() + .find(|style| style.name.eq_ignore_ascii_case(name)) + .cloned() + else { + return false; + }; + self.style_name = style.name; + self.fixed_height = style.height > 1.0e-9; + if self.fixed_height { + self.height = style.height; + } else if style.last_height > 1.0e-9 { + self.height = style.last_height; + } + self.width_factor = style.width_factor.max(0.01); + self.oblique_angle = style.oblique_angle.clamp( + -85.0_f64.to_radians(), + 85.0_f64.to_radians(), + ); + self.annotative = style.annotative; + true + } + + fn set_justification(&mut self, value: &str) -> bool { + let normalized = value.trim().to_ascii_uppercase().replace([' ', '-'], ""); + let alignment = match normalized.as_str() { + "L" | "LEFT" => (HA::Left, VA::Baseline), + "C" | "CENTER" => (HA::Center, VA::Baseline), + "R" | "RIGHT" => (HA::Right, VA::Baseline), + "A" | "ALIGNED" | "ALIGN" => (HA::Aligned, VA::Baseline), + "M" | "MIDDLE" => (HA::Middle, VA::Baseline), + "F" | "FIT" => (HA::Fit, VA::Baseline), + "TL" | "TOPLEFT" => (HA::Left, VA::Top), + "TC" | "TOPCENTER" => (HA::Center, VA::Top), + "TR" | "TOPRIGHT" => (HA::Right, VA::Top), + "ML" | "MIDDLELEFT" => (HA::Left, VA::Middle), + "MC" | "MIDDLECENTER" => (HA::Center, VA::Middle), + "MR" | "MIDDLERIGHT" => (HA::Right, VA::Middle), + "BL" | "BOTTOMLEFT" => (HA::Left, VA::Bottom), + "BC" | "BOTTOMCENTER" => (HA::Center, VA::Bottom), + "BR" | "BOTTOMRIGHT" => (HA::Right, VA::Bottom), + _ => return false, + }; + self.horizontal = alignment.0; + self.vertical = alignment.1; + true + } + + fn is_two_point(&self) -> bool { + matches!(self.horizontal, HA::Aligned | HA::Fit) + } + + fn after_first_point(&mut self) -> CmdResult { + if self.is_two_point() { + self.step = Step::SecondPoint; + } else if self.fixed_height { + self.step = Step::Rotation; + } else { + self.step = Step::Height; + } + CmdResult::NeedPoint + } + + fn after_second_point(&mut self) -> CmdResult { + if matches!(self.horizontal, HA::Fit) && !self.fixed_height { + self.step = Step::Height; + CmdResult::NeedPoint + } else { + self.open_editor() + } + } + + fn make_entity(&self) -> Option { + let first = self.plane.to_local(self.first_point?); + let mut text = Text::with_value("", Vector3::new(first.x, first.y, first.z)) + .with_height(self.height.max(1.0e-9)); + text.style = self.style_name.clone(); + text.width_factor = self.width_factor.max(0.01); + text.oblique_angle = self.oblique_angle; + text.rotation = self.rotation; + text.horizontal_alignment = self.horizontal; + text.vertical_alignment = self.vertical; + text.alignment_point = if self.is_two_point() { + let second = self.plane.to_local(self.second_point?); + Some(Vector3::new(second.x, second.y, second.z)) + } else if matches!((self.horizontal, self.vertical), (HA::Left, VA::Baseline)) { + None + } else { + Some(Vector3::new(first.x, first.y, first.z)) + }; + Some(text) + } + + fn open_editor(&mut self) -> CmdResult { + let Some(entity) = self.make_entity() else { + return CmdResult::NeedPoint; + }; + let pos = self.first_point.unwrap_or(DVec3::ZERO); + self.last_display_height = None; + self.last_entity = Some(entity.clone()); + CmdResult::SuspendForTextInput { pos, entity } + } + + fn open_next_line(&mut self) -> CmdResult { + let Some(mut entity) = self.last_entity.take() else { + return CmdResult::Cancel; + }; + let angle = if matches!(entity.horizontal_alignment, HA::Aligned | HA::Fit) { + entity.alignment_point.map_or(entity.rotation, |point| { + (point.y - entity.insertion_point.y) + .atan2(point.x - entity.insertion_point.x) + }) + } else { + entity.rotation + }; + let fallback_height = entity.height.max(1.0e-9) + * if self.annotative { + self.annotation_multiplier + } else { + 1.0 + }; + let spacing = self + .last_display_height + .take() + .unwrap_or(fallback_height) + .max(1.0e-9) + * 1.666_666_666_7; + let delta = Vector3::new(angle.sin() * spacing, -angle.cos() * spacing, 0.0); + entity.insertion_point = entity.insertion_point + delta; + if let Some(point) = entity.alignment_point.as_mut() { + *point = *point + delta; + } + entity.value.clear(); + let local = DVec3::new( + entity.insertion_point.x, + entity.insertion_point.y, + entity.insertion_point.z, + ); + let pos = self.plane.to_world(local); + self.last_entity = Some(entity.clone()); + CmdResult::SuspendForTextInput { pos, entity } } } impl CadCommand for TextCommand { + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + fn name(&self) -> &'static str { "TEXT" } fn prompt(&self) -> String { - match &self.step { - Step::InsertPoint => t!("TEXT Specify insertion point:").into_owned(), + match self.step { + Step::Start => format!( + "{}\n{}", + crate::tf!( + "TEXT Current style: {}, Height: {}, Annotative: {}", + self.style_name, + self.height, + if self.annotative { "Yes" } else { "No" } + ), + t!("TEXT Specify start point or [Justify/Style]:") + ), + Step::Justification => t!( + "TEXT Enter justification [Left/Center/Right/Aligned/Middle/Fit/TL/TC/TR/ML/MC/MR/BL/BC/BR]:" + ) + .into_owned(), + Step::Style => crate::tf!("TEXT Enter style name <{}>:", self.style_name).into_owned(), + Step::Height if self.annotative => { + crate::tf!("TEXT Specify paper text height <{}>:", self.height).into_owned() + } + Step::Height => crate::tf!("TEXT Specify height <{}>:", self.height).into_owned(), + Step::Rotation => crate::tf!( + "TEXT Specify rotation angle <{}>:", + self.rotation.to_degrees() + ) + .into_owned(), + Step::SecondPoint => t!("TEXT Specify second endpoint:").into_owned(), } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - // Hand off to the in-place plain-text editor anchored at the click. - CmdResult::OpenTextEditor { - pos: pt, - handle: None, - initial: String::new(), - height: self.height, + fn options(&self) -> Vec { + match self.step { + Step::Start => vec![ + CmdOption::new(t!("Justify").as_ref(), "J"), + CmdOption::new(t!("Style").as_ref(), "ST"), + ], + Step::Justification => [ + ("Left", "L"), ("Center", "C"), ("Right", "R"), + ("Aligned", "A"), ("Middle", "M"), ("Fit", "F"), + ("TL", "TL"), ("TC", "TC"), ("TR", "TR"), + ("ML", "ML"), ("MC", "MC"), ("MR", "MR"), + ("BL", "BL"), ("BC", "BC"), ("BR", "BR"), + ] + .into_iter() + .map(|(label, keyword)| CmdOption::new(label, keyword)) + .collect(), + Step::Style => self + .styles + .iter() + .map(|style| CmdOption::new(&style.name, &style.name)) + .collect(), + _ => Vec::new(), + } + } + + fn wants_text_input(&self) -> bool { + matches!( + self.step, + Step::Justification | Step::Style | Step::Height | Step::Rotation + ) + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::Start) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let token = text.trim(); + let upper = token.to_ascii_uppercase(); + match self.step { + Step::Start => match upper.as_str() { + "J" | "JUSTIFY" | "JUSTIFICATION" => self.step = Step::Justification, + "S" | "ST" | "STYLE" => self.step = Step::Style, + _ => return None, + }, + Step::Justification => { + if !self.set_justification(token) { + return None; + } + self.step = Step::Start; + } + Step::Style => { + if !self.select_style(token) { + return None; + } + self.step = Step::Start; + } + Step::Height => { + let value = token.replace(',', ".").parse::().ok()?; + if !value.is_finite() || value <= 1.0e-9 { + return None; + } + self.height = value; + if self.is_two_point() { + return Some(self.open_editor()); + } + self.step = Step::Rotation; + } + Step::Rotation => { + let value = token.replace(',', ".").parse::().ok()?; + if !value.is_finite() { + return None; + } + self.rotation = value.to_radians(); + return Some(self.open_editor()); + } + Step::SecondPoint => return None, + } + Some(CmdResult::NeedPoint) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::Start => { + self.first_point = Some(point); + self.second_point = None; + self.after_first_point() + } + Step::Height => { + let Some(first) = self.first_point else { + return CmdResult::NeedPoint; + }; + let value = self + .plane + .vector_to_local(point - first) + .truncate() + .length(); + if value <= 1.0e-9 { + return CmdResult::NeedPoint; + } + self.height = value; + if self.is_two_point() { + self.open_editor() + } else { + self.step = Step::Rotation; + CmdResult::NeedPoint + } + } + Step::Rotation => { + let Some(first) = self.first_point else { + return CmdResult::NeedPoint; + }; + let Some(angle) = self.plane.angle(first, point) else { + return CmdResult::NeedPoint; + }; + self.rotation = angle; + self.open_editor() + } + Step::SecondPoint => { + let Some(first) = self.first_point else { + return CmdResult::NeedPoint; + }; + if self + .plane + .vector_to_local(point - first) + .truncate() + .length() + <= 1.0e-9 + { + return CmdResult::NeedPoint; + } + self.second_point = Some(point); + self.after_second_point() + } + Step::Justification | Step::Style => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + Step::Start => CmdResult::Cancel, + Step::Justification | Step::Style => { + self.step = Step::Start; + CmdResult::NeedPoint + } + Step::Height => { + if self.is_two_point() { + self.open_editor() + } else { + self.step = Step::Rotation; + CmdResult::NeedPoint + } + } + Step::Rotation => self.open_editor(), + Step::SecondPoint => CmdResult::NeedPoint, + } } + + fn on_editor_closed(&mut self, committed: bool) -> CmdResult { + if committed { + self.open_next_line() + } else { + CmdResult::Cancel + } + } + + fn on_editor_display_height(&mut self, height: f64) { + if height.is_finite() && height > 1.0e-9 { + self.last_display_height = Some(height); + } + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } - fn on_mouse_move(&mut self, _pt: DVec3) -> Option { + fn on_mouse_move(&mut self, _point: DVec3) -> Option { None } } - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] }); // TextCommand +inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] }); diff --git a/src/modules/annotate/tolerance_cmd.rs b/src/modules/annotate/tolerance_cmd.rs index 3033e969..192835bc 100644 --- a/src/modules/annotate/tolerance_cmd.rs +++ b/src/modules/annotate/tolerance_cmd.rs @@ -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>, plane: WorkingPlane, } impl ToleranceCommand { - pub fn new() -> Self { + pub fn with_text(text: String, preview_strokes: Vec>) -> 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 { - 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,20 +68,22 @@ impl CadCommand for ToleranceCommand { } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - 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(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -118,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, diff --git a/src/modules/draw/defaults.rs b/src/modules/draw/defaults.rs index a3598fa8..f21a40f8 100644 --- a/src/modules/draw/defaults.rs +++ b/src/modules/draw/defaults.rs @@ -26,6 +26,8 @@ thread_local! { static RECT_CHAMFER1: Cell = Cell::new(0.0); static RECT_CHAMFER2: Cell = Cell::new(0.0); static RECT_FILLET: Cell = Cell::new(0.0); + static DONUT_INNER_DIAMETER: Cell = Cell::new(0.5); + static DONUT_OUTER_DIAMETER: Cell = Cell::new(1.0); } macro_rules! accessors { @@ -68,3 +70,13 @@ accessors!(get_rect_rotation, set_rect_rotation, RECT_ROTATION); accessors!(get_rect_chamfer1, set_rect_chamfer1, RECT_CHAMFER1); accessors!(get_rect_chamfer2, set_rect_chamfer2, RECT_CHAMFER2); accessors!(get_rect_fillet, set_rect_fillet, RECT_FILLET); +accessors!( + get_donut_inner_diameter, + set_donut_inner_diameter, + DONUT_INNER_DIAMETER +); +accessors!( + get_donut_outer_diameter, + set_donut_outer_diameter, + DONUT_OUTER_DIAMETER +); diff --git a/src/modules/draw/draw/attdef.rs b/src/modules/draw/draw/attdef.rs index 33551f5a..5d6c46db 100644 --- a/src/modules/draw/draw/attdef.rs +++ b/src/modules/draw/draw/attdef.rs @@ -203,6 +203,7 @@ impl CadCommand for AttdefCommand { Some(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/draw/draw/donut.rs b/src/modules/draw/draw/donut.rs index c459492a..fe1fb841 100644 --- a/src/modules/draw/draw/donut.rs +++ b/src/modules/draw/draw/donut.rs @@ -1,21 +1,19 @@ -// DONUT command — create a filled circular ring (thick LwPolyline). -// -// A donut is an LwPolyline with: -// - 2 vertices at (cx ± r_avg, 0), both with bulge = 1.0 (two 180° CCW arcs) -// - start_width = end_width = (outer - inner) / 2 -// - is_closed = true -// -// Workflow: -// 1. Type inner diameter (or 0 for a filled circle) -// 2. Type outer diameter -// 3. Click center point(s); Enter to finish +// DONUT creates a ring as a closed, constant-width LwPolyline. use acadrust::entities::{LwPolyline, LwVertex}; use acadrust::EntityType; +use cadkernel::geom2d::{ + Circle as KernelCircle, Curve as KernelCurve, Vec2 as KernelVec2, +}; use glam::DVec3; + use crate::t; use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::modules::draw::defaults; +use crate::scene::model::wire_model::WireModel; + +const TAU: f64 = std::f64::consts::TAU; pub struct DonutCommand { state: DonutState, @@ -24,21 +22,64 @@ pub struct DonutCommand { plane: WorkingPlane, } +#[derive(Clone, Copy)] enum DonutState { AskInner, + AskInnerSecond(DVec3), AskOuter, + AskOuterSecond(DVec3), PlaceCenter, } impl DonutCommand { pub fn new() -> Self { + let mut inner_diameter = defaults::get_donut_inner_diameter(); + let mut outer_diameter = defaults::get_donut_outer_diameter(); + if inner_diameter > outer_diameter { + std::mem::swap(&mut inner_diameter, &mut outer_diameter); + } Self { state: DonutState::AskInner, - inner_r: 0.0, - outer_r: 1.0, + inner_r: inner_diameter * 0.5, + outer_r: outer_diameter * 0.5, plane: WorkingPlane::default(), } } + + fn set_inner_diameter(&mut self, diameter: f64) { + self.inner_r = diameter * 0.5; + self.state = DonutState::AskOuter; + } + + fn set_outer_diameter(&mut self, diameter: f64) { + self.outer_r = diameter * 0.5; + if self.inner_r > self.outer_r { + std::mem::swap(&mut self.inner_r, &mut self.outer_r); + } + defaults::set_donut_inner_diameter(self.inner_r * 2.0); + defaults::set_donut_outer_diameter(self.outer_r * 2.0); + self.state = DonutState::PlaceCenter; + } + + fn point_distance(&self, first: DVec3, second: DVec3) -> f64 { + let first = self.plane.to_local(first); + let second = self.plane.to_local(second); + KernelVec2::new(first.x, first.y).distance(KernelVec2::new(second.x, second.y)) + } + + fn project_to_plane(&self, point: DVec3) -> DVec3 { + let local = self.plane.to_local(point); + self.plane.to_world(DVec3::new(local.x, local.y, 0.0)) + } +} + +fn prompt_with_default(prompt: &str, value: f64) -> String { + match prompt.char_indices().next_back() { + Some((index, suffix @ (':' | ':'))) => { + format!("{} <{value:.4}>{suffix}", &prompt[..index]) + } + _ => format!("{prompt} <{value:.4}>"), + } } impl CadCommand for DonutCommand { @@ -51,9 +92,19 @@ impl CadCommand for DonutCommand { } fn prompt(&self) -> String { - match &self.state { - DonutState::AskInner => t!("DONUT Specify inside diameter <0>:").into_owned(), - DonutState::AskOuter => t!("DONUT Specify outside diameter:").into_owned(), + match self.state { + DonutState::AskInner => t!("DONUT Specify inside diameter <0>:") + .replace("<0>", &format!("<{:.4}>", self.inner_r * 2.0)), + DonutState::AskInnerSecond(_) => { + t!("DONUT Specify second point for inside diameter:").into_owned() + } + DonutState::AskOuter => prompt_with_default( + t!("DONUT Specify outside diameter:").as_ref(), + self.outer_r * 2.0, + ), + DonutState::AskOuterSecond(_) => { + t!("DONUT Specify second point for outside diameter:").into_owned() + } DonutState::PlaceCenter => { t!("DONUT Specify center of donut (Enter to exit):").into_owned() } @@ -78,22 +129,20 @@ impl CadCommand for DonutCommand { .replace(',', ".") .parse() .ok() - .filter(|&v: &f64| v >= 0.0)?; - match &self.state { + .filter(|v: &f64| v.is_finite())?; + match self.state { DonutState::AskInner => { - self.inner_r = val / 2.0; - self.state = DonutState::AskOuter; + if val < 0.0 { + return Some(CmdResult::NeedPoint); + } + self.set_inner_diameter(val); Some(CmdResult::NeedPoint) } DonutState::AskOuter => { if val <= 0.0 { - return None; + return Some(CmdResult::NeedPoint); } - self.outer_r = val / 2.0; - if self.inner_r > self.outer_r { - std::mem::swap(&mut self.inner_r, &mut self.outer_r); - } - self.state = DonutState::PlaceCenter; + self.set_outer_diameter(val); Some(CmdResult::NeedPoint) } _ => None, @@ -101,29 +150,107 @@ impl CadCommand for DonutCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { - match &self.state { + match self.state { + DonutState::AskInner => { + self.state = DonutState::AskInnerSecond(self.project_to_plane(pt)); + CmdResult::NeedPoint + } + DonutState::AskInnerSecond(first) => { + let diameter = self.point_distance(first, pt); + if diameter <= 1.0e-9 { + return CmdResult::NeedPoint; + } + self.set_inner_diameter(diameter); + CmdResult::NeedPoint + } + DonutState::AskOuter => { + self.state = DonutState::AskOuterSecond(self.project_to_plane(pt)); + CmdResult::NeedPoint + } + DonutState::AskOuterSecond(first) => { + let diameter = self.point_distance(first, pt); + if diameter <= 1.0e-9 { + return CmdResult::NeedPoint; + } + self.set_outer_diameter(diameter); + CmdResult::NeedPoint + } DonutState::PlaceCenter => { let center = self.plane.to_local(pt); let entity = make_donut(center.x, center.y, center.z, self.inner_r, self.outer_r); // Keep command active so user can place more donuts. CmdResult::CommitEntity(self.plane.place_entity(entity)) } - _ => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { - match &self.state { + match self.state { DonutState::AskInner => { - // Accept default 0 for inner diameter - self.inner_r = 0.0; self.state = DonutState::AskOuter; CmdResult::NeedPoint } + DonutState::AskOuter => { + self.set_outer_diameter(self.outer_r * 2.0); + CmdResult::NeedPoint + } DonutState::PlaceCenter => CmdResult::Cancel, _ => CmdResult::Cancel, } } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + match self.state { + DonutState::AskInnerSecond(first) | DonutState::AskOuterSecond(first) => { + let point = self.project_to_plane(pt); + Some(WireModel::solid_f64( + "rubber_band".into(), + vec![first.to_array(), point.to_array()], + WireModel::CYAN, + false, + )) + } + DonutState::PlaceCenter => Some(donut_wire( + pt, + self.inner_r, + self.outer_r, + self.plane, + )), + _ => None, + } + } +} + +fn donut_wire( + center: DVec3, + inner_r: f64, + outer_r: f64, + plane: WorkingPlane, +) -> WireModel { + let local_center = plane.to_local(center); + let mut points = Vec::new(); + for radius in [outer_r, inner_r] { + if radius <= 1.0e-9 { + continue; + } + if !points.is_empty() { + points.push([f64::NAN; 3]); + } + points.extend( + KernelCurve::Circle(KernelCircle { + centre: [local_center.x, local_center.y], + radius, + }) + .tessellate_angle(TAU / 64.0) + .into_iter() + .map(|point| { + plane + .to_world(DVec3::new(point[0], point[1], local_center.z)) + .to_array() + }), + ); + } + WireModel::solid_f64("rubber_band".into(), points, WireModel::CYAN, false) } fn make_donut(cx: f64, cy: f64, elevation: f64, inner_r: f64, outer_r: f64) -> EntityType { @@ -136,17 +263,12 @@ fn make_donut(cx: f64, cy: f64, elevation: f64, inner_r: f64, outer_r: f64) -> E p.constant_width = width; p.elevation = elevation; - // Vertex at (cx - r, cy) with bulge 1.0 (180° CCW arc to next vertex) + // Segment widths inherit the polyline's constant width. let mut v0 = LwVertex::new(Vector2::new(cx - r_avg, cy)); v0.bulge = 1.0; - v0.start_width = width; - v0.end_width = width; - // Vertex at (cx + r, cy) with bulge 1.0 (second 180° arc back to v0) let mut v1 = LwVertex::new(Vector2::new(cx + r_avg, cy)); v1.bulge = 1.0; - v1.start_width = width; - v1.end_width = width; p.vertices = vec![v0, v1]; EntityType::LwPolyline(p) diff --git a/src/modules/draw/draw/mline.rs b/src/modules/draw/draw/mline.rs index 9e03c457..c476b235 100644 --- a/src/modules/draw/draw/mline.rs +++ b/src/modules/draw/draw/mline.rs @@ -182,6 +182,7 @@ impl MlineCommand { Some(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/draw/draw/raster_image.rs b/src/modules/draw/draw/raster_image.rs index ac973aca..f2001719 100644 --- a/src/modules/draw/draw/raster_image.rs +++ b/src/modules/draw/draw/raster_image.rs @@ -125,6 +125,7 @@ impl CadCommand for ImageCommand { Some(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index 47bcf9a8..63d374d0 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -87,6 +87,7 @@ impl CadCommand for RayCommand { Some(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -190,6 +191,7 @@ impl CadCommand for XLineCommand { Some(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/draw/inquiry/area.rs b/src/modules/draw/inquiry/area.rs index 6ae6386e..de733031 100644 --- a/src/modules/draw/inquiry/area.rs +++ b/src/modules/draw/inquiry/area.rs @@ -364,6 +364,7 @@ impl CadCommand for AreaCommand { Some(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/draw/inquiry/dist.rs b/src/modules/draw/inquiry/dist.rs index d0a3ac0f..a0527485 100644 --- a/src/modules/draw/inquiry/dist.rs +++ b/src/modules/draw/inquiry/dist.rs @@ -87,6 +87,7 @@ impl CadCommand for DistCommand { Some(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/draw/inquiry/measuregeom.rs b/src/modules/draw/inquiry/measuregeom.rs index 17d5d72c..25e47ac6 100644 --- a/src/modules/draw/inquiry/measuregeom.rs +++ b/src/modules/draw/inquiry/measuregeom.rs @@ -72,6 +72,7 @@ impl MeasureGeomCommand { WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/draw/modify/align.rs b/src/modules/draw/modify/align.rs index ad6122ef..8cd4109c 100644 --- a/src/modules/draw/modify/align.rs +++ b/src/modules/draw/modify/align.rs @@ -14,6 +14,7 @@ use glam::DVec3; use crate::t; use crate::command::{CadCommand, CmdResult, EntityTransform}; +use crate::scene::model::wire_model::WireModel; pub struct AlignCommand { state: AlignState, @@ -35,10 +36,16 @@ enum AlignState { } impl AlignCommand { - pub fn new() -> Self { + pub fn with_selection(handles: Vec) -> Self { + let state = if handles.is_empty() { + AlignState::Gathering + } else { + AlignState::Src1 + }; + Self { - state: AlignState::Gathering, - handles: vec![], + state, + handles, src1: None, dst1: None, src2: None, @@ -66,11 +73,26 @@ impl CadCommand for AlignCommand { } AlignState::Dst2 => t!("ALIGN Specify 2nd destination point:").into_owned(), AlignState::AskScale => { - t!("ALIGN Scale objects based on alignment points? [Y/N]:").into_owned() + t!( + "ALIGN Scale objects based on alignment points? [Yes / No]:" + ) + .into_owned() } } } + fn options(&self) -> Vec { + use crate::command::CmdOption; + + match self.state { + AlignState::AskScale => vec![ + CmdOption::new(t!("Yes").as_ref(), "Y"), + CmdOption::new(t!("No").as_ref(), "N"), + ], + _ => vec![], + } + } + fn is_selection_gathering(&self) -> bool { self.state == AlignState::Gathering } @@ -113,14 +135,17 @@ impl CadCommand for AlignCommand { if self.handles.is_empty() { return CmdResult::Cancel; } + self.state = AlignState::Src1; CmdResult::NeedPoint } + AlignState::Src2 => { - // Only 1 pair — pure translation + // One alignment pair only: translation. match (self.src1, self.dst1) { (Some(s), Some(d)) => { let delta = d - s; + CmdResult::TransformSelected( self.handles.clone(), EntityTransform::Translate(delta), @@ -129,10 +154,10 @@ impl CadCommand for AlignCommand { _ => CmdResult::Cancel, } } - AlignState::AskScale => { - // No scale (default N) - self.compute_align(false) - } + + // Default option shown as . + AlignState::AskScale => self.compute_align(false), + _ => CmdResult::Cancel, } } @@ -145,8 +170,76 @@ impl CadCommand for AlignCommand { if self.state != AlignState::AskScale { return None; } - let scale = text.trim().to_uppercase().starts_with('Y'); - Some(self.compute_align(scale)) + + match text.trim().to_ascii_lowercase().as_str() { + "y" | "yes" | "scale" => { + Some(self.compute_align(true)) + } + + "n" | "no" | "don't scale" | "dont scale" | "noscale" => { + Some(self.compute_align(false)) + } + + _ => Some(CmdResult::NeedPoint), + } + } + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + fn line(a: DVec3, b: DVec3, name: &str) -> WireModel { + WireModel::solid( + name.into(), + vec![ + [a.x as f32, a.y as f32, a.z as f32], + [b.x as f32, b.y as f32, b.z as f32], + ], + WireModel::CYAN, + false, + ) + } + + let mut out = Vec::new(); + + // Keep the first completed alignment pair visible while defining + // the second pair and while choosing the scale option. + if let (Some(src1), Some(dst1)) = (self.src1, self.dst1) { + match self.state { + AlignState::Src2 + | AlignState::Dst2 + | AlignState::AskScale => { + out.push(line(src1, dst1, "align_pair_1")); + } + _ => {} + } + } + + match self.state { + // First source has been picked: stretch its reference line + // to the cursor until the first destination is chosen. + AlignState::Dst1 => { + if let Some(src1) = self.src1 { + out.push(line(src1, pt, "align_pair_1_preview")); + } + } + + // Second source has been picked: stretch the second reference + // line to the cursor until its destination is chosen. + AlignState::Dst2 => { + if let Some(src2) = self.src2 { + out.push(line(src2, pt, "align_pair_2_preview")); + } + } + + // Once both pairs are complete, keep both visible while + // waiting for the Scale / No Scale decision. + AlignState::AskScale => { + if let (Some(src2), Some(dst2)) = (self.src2, self.dst2) { + out.push(line(src2, dst2, "align_pair_2")); + } + } + + _ => {} + } + + out } } diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index 302033f9..f8626854 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -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; @@ -612,10 +612,66 @@ struct DimMetrics { /// suppression), DIMCLRD/E/T (colours) and DIMLWD/E (lineweights). fn dim_metrics(dim: &Dimension, doc: &CadDocument) -> DimMetrics { let name = dim.base().style_name.as_str(); - let style = doc.dim_styles.iter().find(|s| { + let mut effective_style = doc.dim_styles.iter().find(|s| { s.name.eq_ignore_ascii_case(name) || (name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard")) - }); + }).cloned(); + if let Some(style) = &mut effective_style { + use crate::entities::dim_override as ov; + let data = &dim.base().common.extended_data; + macro_rules! real { + ($field:ident, $code:ident) => { + if let Some(value) = ov::real(data, ov::$code) { + style.$field = value; + } + }; + } + macro_rules! int { + ($field:ident, $code:ident) => { + if let Some(value) = ov::int(data, ov::$code) { + style.$field = value; + } + }; + } + macro_rules! flag { + ($field:ident, $code:ident) => { + if let Some(value) = ov::int(data, ov::$code) { + style.$field = value != 0; + } + }; + } + macro_rules! handle { + ($field:ident, $code:ident) => { + if let Some(value) = ov::handle(data, ov::$code) { + style.$field = value; + } + }; + } + real!(dimscale, DIMSCALE); + real!(dimasz, DIMASZ); + real!(dimcen, DIMCEN); + real!(dimexo, DIMEXO); + real!(dimexe, DIMEXE); + real!(dimtsz, DIMTSZ); + real!(dimdle, DIMDLE); + real!(dimfxl, DIMFXL); + flag!(dimfxlon, DIMFXLON); + flag!(dimse1, DIMSE1); + flag!(dimse2, DIMSE2); + flag!(dimsd1, DIMSD1); + flag!(dimsd2, DIMSD2); + flag!(dimsoxd, DIMSOXD); + flag!(dimsah, DIMSAH); + int!(dimclrd, DIMCLRD); + int!(dimclre, DIMCLRE); + int!(dimclrt, DIMCLRT); + int!(dimlwd, DIMLWD); + int!(dimlwe, DIMLWE); + handle!(dimblk, DIMBLK); + handle!(dimblk1, DIMBLK1); + handle!(dimblk2, DIMBLK2); + } + let style = effective_style.as_ref(); let scale = style .map(|s| if s.dimscale > 1e-6 { s.dimscale } else { 1.0 }) .unwrap_or(1.0); @@ -630,7 +686,15 @@ fn dim_metrics(dim: &Dimension, doc: &CadDocument) -> DimMetrics { let t = ArrowKind::Tick { size: dimtsz as f32 }; (t.clone(), t) } else if let Some(s) = style { - if s.dimsah { + if matches!(dim, Dimension::Diameter(_)) { + use crate::entities::dim_override as ov; + let data = &dim.base().common.extended_data; + let first = ov::handle(data, ov::DIMBLK1) + .unwrap_or(if s.dimsah { s.dimblk1 } else { s.dimblk }); + let second = ov::handle(data, ov::DIMBLK2) + .unwrap_or(if s.dimsah { s.dimblk2 } else { s.dimblk }); + (arrow_from_block(doc, first, asz), arrow_from_block(doc, second, asz)) + } else if s.dimsah { (arrow_from_block(doc, s.dimblk1, asz), arrow_from_block(doc, s.dimblk2, asz)) } else { let a = arrow_from_block(doc, s.dimblk, asz); @@ -845,6 +909,42 @@ fn norm2(dx: f64, dy: f64, fx: f64, fy: f64) -> (f64, f64) { } fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { + 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); @@ -993,27 +1093,101 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { result.extend(dim_center_mark(center, met.dimcen, len, &dim_c)); } Dimension::Diameter(d) => { - // Full diameter through the centre (far edge -> near edge), inward - // terminators at both edges, plus the centre mark. - let (center, edge) = (d.angle_vertex, d.definition_point); - let far = v3(2.0 * center.x - edge.x, 2.0 * center.y - edge.y, edge.z); - result.push(make_seg(&far, &edge, &dim_c)); + let (edge, far) = (d.angle_vertex, d.definition_point); + let center = v3( + (edge.x + far.x) * 0.5, + (edge.y + far.y) * 0.5, + (edge.z + far.z) * 0.5, + ); let len = ((edge.x - far.x).powi(2) + (edge.y - far.y).powi(2)) .sqrt() .max(1e-12); let (ux, uy) = ((edge.x - far.x) / len, (edge.y - far.y) / len); - result.extend(dim_terminator(edge, -ux, -uy, &met.arrow1, &dim_c)); - result.extend(dim_terminator(far, ux, uy, &met.arrow2, &dim_c)); - // Optional leader past the near edge toward the text. DIM-DIA-LEADER. - if d.leader_length.abs() > 1e-9 { - let anchor = dim_text_anchor(base, center, edge); - let ld = norm2(anchor.x - edge.x, anchor.y - edge.y, ux, uy); + let ticks = met.dimtsz > 1e-9; + let extension = if ticks { met.dimdle } else { 0.0 }; + let edge_outer = v3( + edge.x + ux * extension, + edge.y + uy * extension, + edge.z, + ); + let far_outer = v3( + far.x - ux * extension, + far.y - uy * extension, + far.z, + ); + if !met.dimsd1 { + result.push(make_seg(&edge_outer, ¢er, &dim_c)); + } + if !met.dimsd2 { + result.push(make_seg(¢er, &far_outer, &dim_c)); + } + let outside = !ticks && met.dimasz > 1e-6 && len < 2.0 * met.dimasz; + if outside { + result.extend(dim_terminator(edge, ux, uy, &met.arrow1, &dim_c)); + result.extend(dim_terminator(far, -ux, -uy, &met.arrow2, &dim_c)); + if !met.dimsoxd { + let stub = 2.0 * met.dimasz; + if !met.dimsd1 { + result.push(make_seg( + &edge, + &v3(edge.x + ux * stub, edge.y + uy * stub, edge.z), + &dim_c, + )); + } + if !met.dimsd2 { + result.push(make_seg( + &far, + &v3(far.x - ux * stub, far.y - uy * stub, far.z), + &dim_c, + )); + } + } + } else { + result.extend(dim_terminator(edge, -ux, -uy, &met.arrow1, &dim_c)); + result.extend(dim_terminator(far, ux, uy, &met.arrow2, &dim_c)); + } + let anchor = dim_text_anchor(base, center, edge); + let distance_squared = |first: Vector3, second: Vector3| { + (first.x - second.x).powi(2) + + (first.y - second.y).powi(2) + + (first.z - second.z).powi(2) + }; + let (leader_tip, suppressed, fallback) = if distance_squared(anchor, edge) + <= distance_squared(anchor, far) + { + (edge, met.dimsd1, (ux, uy)) + } else { + (far, met.dimsd2, (-ux, -uy)) + }; + if !suppressed && d.leader_length.abs() > 1e-9 { + let ld = norm2( + anchor.x - leader_tip.x, + anchor.y - leader_tip.y, + fallback.0, + fallback.1, + ); result.push(make_seg( - &edge, - &v3(edge.x + ld.0 * d.leader_length, edge.y + ld.1 * d.leader_length, edge.z), + &leader_tip, + &v3( + leader_tip.x + ld.0 * d.leader_length.abs(), + leader_tip.y + ld.1 * d.leader_length.abs(), + leader_tip.z, + ), &dim_c, )); } + if !met.dimse1 { + if let Some(points) = crate::scene::dimension_assoc::radial_extension_points( + doc, + base.common.handle, + met.dimexo, + met.dimexe, + ) { + for pair in points.windows(2) { + result.push(make_seg(&pair[0], &pair[1], &ext_c)); + } + } + } result.extend(dim_center_mark(center, met.dimcen, len * 0.5, &dim_c)); } Dimension::Angular2Ln(d) => { @@ -1041,10 +1215,19 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { )); } Dimension::Ordinate(d) => { - result.push(make_seg(&d.feature_location, &d.definition_point, &dim_c)); - result.push(make_seg(&d.definition_point, &d.leader_endpoint, &dim_c)); + if !met.dimse1 { + let fixed_length = met.dimfxlon.then_some(met.dimfxl); + let points = d.leader_polyline(met.dimasz * 2.0, met.dimexo, fixed_length); + for pair in points.windows(2) { + if (pair[1] - pair[0]).length() > 1e-12 { + result.push(make_seg(&pair[0], &pair[1], &ext_c)); + } + } + } } 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, @@ -1053,10 +1236,7 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { &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( @@ -1073,9 +1253,11 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { d.override_center, d.jog_angle, ); - result.push(make_seg(&d.chord_point, &near, &dim_c)); - result.push(make_seg(&near, &far, &dim_c)); - result.push(make_seg(&far, &d.override_center, &dim_c)); + if !met.dimsd1 { + result.push(make_seg(&d.chord_point, &near, &dim_c)); + result.push(make_seg(&near, &far, &dim_c)); + result.push(make_seg(&far, &d.override_center, &dim_c)); + } let len = ((near.x - d.chord_point.x).powi(2) + (near.y - d.chord_point.y).powi(2)) .sqrt() @@ -1087,6 +1269,24 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { &met.arrow1, &dim_c, )); + let radius = ((d.definition_point.x - d.chord_point.x).powi(2) + + (d.definition_point.y - d.chord_point.y).powi(2)) + .sqrt(); + result.extend(dim_center_mark( + d.definition_point, + met.dimcen, + radius, + &dim_c, + )); + } + } + + 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)); } } @@ -1566,16 +1766,15 @@ mod tests { assert!(lines > 5, "angular bake must include arc chords, got {lines} lines"); } - // A diameter dimension bakes a line edge-to-edge THROUGH the centre, not a - // radius-length line. The two extreme endpoints must be equidistant from the - // centre (angle_vertex) and the centre must lie between them. + // Diameter endpoints stay equidistant from the circle center. #[test] fn diameter_dim_bakes_through_center() { use acadrust::entities::DimensionDiameter; let mut doc = CadDocument::new(); let center = Vector3::new(3.0, 4.0, 0.0); let edge = Vector3::new(8.0, 4.0, 0.0); // radius 5 along +x - let mut d = DimensionDiameter::new(center, edge); + let far = Vector3::new(-2.0, 4.0, 0.0); + let mut d = DimensionDiameter::new(edge, far); d.base.text_middle_point = Vector3::new(3.0, 9.0, 0.0); let handle = doc .add_entity(EntityType::Dimension(Dimension::Diameter(d))) diff --git a/src/modules/draw/modify/offset.rs b/src/modules/draw/modify/offset.rs index d3b5cc0a..20a988ac 100644 --- a/src/modules/draw/modify/offset.rs +++ b/src/modules/draw/modify/offset.rs @@ -415,19 +415,19 @@ fn entity_wire_pts(e: &EntityType) -> Vec<[f32; 3]> { // ── Command implementation ───────────────────────────────────────────────── enum Step { - /// Classic first step (#418): type the offset distance, press Enter / - /// Space to accept the last one, or choose Through mode. + /// Type the offset distance, accept the previous value with Enter, + /// choose Through, or pick the first point of a reference distance. Distance, - /// Pick the object to offset. `locked == None` is "through" mode: the - /// magnitude follows the cursor (perpendicular distance to the object). + + /// First reference point has been picked; the second point defines + /// the offset distance. + ReferenceSecond { first: DVec3 }, + SelectObject { locked: Option }, + PickSide { - /// The object(s) being offset — one from a pick, or the whole - /// pre-selection when OFFSET starts with objects selected (#422). targets: Vec, locked: Option, - /// Keep the side-pick step active and use each new result as the source - /// for the next offset. multiple: bool, }, } @@ -499,6 +499,18 @@ impl OffsetCommand { } CmdResult::NeedPoint } + fn accept_distance(&mut self, distance: f64) -> CmdResult { + let distance = distance.abs().max(1e-9); + + defaults::set_offset_dist(distance); + self.advance_from_distance(Some(distance)); + + let d = format!("{:.4}", distance); + + CmdResult::ReportMeasurement( + t!("OFFSET distance = %{d}", d = d).into_owned() + ) + } } impl CadCommand for OffsetCommand { @@ -510,7 +522,15 @@ impl CadCommand for OffsetCommand { match &self.step { Step::Distance => { let d = format!("{:.4}", defaults::get_offset_dist()); - t!("OFFSET Specify offset distance or [Through] <%{d}>:", d = d).into_owned() + t!( + "OFFSET Specify offset distance or first reference point [Through] <%{d}>:", + d = d + ) + .into_owned() + } + + Step::ReferenceSecond { .. } => { + t!("OFFSET Specify second reference point:").into_owned() } Step::SelectObject { .. } => { t!("OFFSET Select object to offset (Enter to finish):").into_owned() @@ -623,7 +643,9 @@ impl CadCommand for OffsetCommand { fn dyn_field(&self) -> crate::command::DynField { match self.step { Step::Distance | Step::PickSide { .. } => crate::command::DynField::Scalar, - _ => crate::command::DynField::Point, + + Step::ReferenceSecond { .. } + | Step::SelectObject { .. } => crate::command::DynField::Point, } } @@ -648,9 +670,7 @@ impl CadCommand for OffsetCommand { return Some(self.advance_from_distance(None)); } if let Some(d) = crate::entities::common::parse_typed_length(&t) { - let d = d.abs().max(1e-9); - defaults::set_offset_dist(d); - return Some(self.advance_from_distance(Some(d))); + return Some(self.accept_distance(d)); } Some(CmdResult::NeedPoint) } @@ -698,12 +718,32 @@ impl CadCommand for OffsetCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + match &self.step { + Step::Distance => { + self.step = Step::ReferenceSecond { first: pt }; + return CmdResult::NeedPoint; + } + + Step::ReferenceSecond { first } => { + let distance = first.distance(pt); + + if distance <= 1e-9 { + return CmdResult::NeedPoint; + } + + return self.accept_distance(distance); + } + + _ => {} + } + let (locked, targets, multiple) = match &self.step { Step::PickSide { locked, targets, multiple, } => (*locked, targets.clone(), *multiple), + _ => return CmdResult::NeedPoint, }; // Each target offsets by its own through-distance (or the locked @@ -745,6 +785,26 @@ impl CadCommand for OffsetCommand { } fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + if let Step::ReferenceSecond { first } = &self.step { + return vec![WireModel::solid( + "offset_reference_distance".into(), + vec![ + [ + first.x as f32, + first.y as f32, + first.z as f32, + ], + [ + pt.x as f32, + pt.y as f32, + pt.z as f32, + ], + ], + WireModel::CYAN, + false, + )]; + } + let (locked, targets) = match &self.step { Step::PickSide { locked, targets, .. } => (*locked, targets.clone()), _ => return vec![], @@ -779,7 +839,7 @@ impl CadCommand for OffsetCommand { // the "repeat the same value with just Space" flow (#418). Step::Distance => { let d = defaults::get_offset_dist(); - self.advance_from_distance(Some(d.abs().max(1e-9))) + self.accept_distance(d) } _ => CmdResult::Cancel, } diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index a04b94aa..039db2b9 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -3721,6 +3721,7 @@ fn preview_wire(points: Vec<[f32; 3]>, color: [f32; 4], name: &str) -> WireModel WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/insert/insert_block.rs b/src/modules/insert/insert_block.rs index 89c3caf6..e54671c9 100644 --- a/src/modules/insert/insert_block.rs +++ b/src/modules/insert/insert_block.rs @@ -22,16 +22,10 @@ enum Step { Point { name: String, }, - /// Attribute filling: prompt the user tag by tag. FillAttr { - /// The block's attribute definitions — carried in full so the created - /// attributes inherit their position / alignment / height / rotation / - /// style, not just tag + prompt + default (#255). attdefs: Vec, - /// Index of the attdef currently being prompted. idx: usize, - /// (tag, value) pairs collected so far, in attdef order. - values: Vec<(String, String)>, + values: Vec<(usize, String)>, }, } @@ -44,7 +38,7 @@ enum AwaitKind { } pub struct InsertBlockCommand { - available: Vec, + picker: crate::modules::insert::picker::BlockPicker, step: Step, /// Uniform X/Y scale applied to the placed block (default 1). x_scale: f64, @@ -63,9 +57,17 @@ pub struct InsertBlockCommand { } impl InsertBlockCommand { - pub fn new(available: Vec) -> Self { + /// Construct with explicit usage ranking. `usage` maps uppercase block name → (frequency, MRU position). + /// `cliprompt_lines` directly controls suggestion count (relation per spec). + pub fn new_with_usage( + available: Vec, + usage_rank: rustc_hash::FxHashMap, + cliprompt_lines: u8, + ) -> Self { + let limit = (cliprompt_lines as usize).clamp(0, crate::modules::insert::picker::MAX_SUGGESTIONS); + let picker = crate::modules::insert::picker::BlockPicker::new(available, usage_rank, limit); Self { - available, + picker, step: Step::Name, x_scale: 1.0, y_scale: 1.0, @@ -82,8 +84,14 @@ impl InsertBlockCommand { /// from `base`) rubber-band under the cursor. Used by paste-as-block, which /// has just defined the block and only needs the drop point. pub fn new_for_block(name: String, preview_wires: Vec, base: Vec3) -> Self { + // Minimal picker for the locked-name path; not used for Name step. + let picker = crate::modules::insert::picker::BlockPicker::new( + vec![name.clone()], + rustc_hash::FxHashMap::default(), + 0, + ); Self { - available: vec![name.clone()], + picker, step: Step::Point { name }, x_scale: 1.0, y_scale: 1.0, @@ -94,6 +102,7 @@ impl InsertBlockCommand { plane: WorkingPlane::default(), } } + } impl CadCommand for InsertBlockCommand { @@ -108,12 +117,39 @@ impl CadCommand for InsertBlockCommand { fn prompt(&self) -> String { match &self.step { Step::Name => { - let hint = if self.available.is_empty() { - String::new() + if self.picker.is_empty() { + return t!("INSERT Enter block name:").into_owned(); + } + let needle = self.picker.needle(); + let filtered = self.picker.filtered(); + if !needle.is_empty() && filtered.is_empty() { + return t!( + "INSERT No matching blocks for \"%{needle}\"", + needle = needle + ) + .into_owned(); + } + if needle.is_empty() { + let total = self.picker.total(); + let shown = filtered.len(); + if total <= shown { + t!("INSERT Enter block name:").into_owned() + } else { + t!( + "INSERT Enter block name: [%{shown} of %{total} — type to search]", + shown = shown, + total = total + ) + .into_owned() + } } else { - format!(" [{}]", self.available.join(", ")) - }; - t!("INSERT Enter block name:%{hint}", hint = hint).into_owned() + t!( + "INSERT Enter block name: \"%{needle}\" [%{shown} matches]", + needle = needle, + shown = filtered.len() + ) + .into_owned() + } } Step::Point { name } => match self.awaiting { Some(AwaitKind::Scale) => t!("INSERT Specify scale factor <1>:").into_owned(), @@ -171,6 +207,32 @@ impl CadCommand for InsertBlockCommand { } } + fn options(&self) -> Vec { + match &self.step { + Step::Name => self + .picker + .filtered() + .iter() + .map(|n| crate::command::CmdOption::new(n, n)) + .collect(), + _ => Vec::new(), + } + } + + fn on_live_input(&mut self, input: &str) -> bool { + if !matches!(self.step, Step::Name) { + return false; + } + // Performance: only recompute if needle actually changed; picker + // uses upper/lower caches and partial sort so per-keystroke is <0.2ms. + let needle = input.trim(); + if needle == self.picker.needle() { + return false; + } + self.picker.set_needle(needle.to_string()); + true + } + fn on_enter(&mut self) -> CmdResult { match &self.step { // A bare Enter while a scale/rotation value is awaited keeps the @@ -210,12 +272,25 @@ impl CadCommand for InsertBlockCommand { match &self.step { Step::Name => { let name = text.trim(); - if !self.available.iter().any(|c| c.eq_ignore_ascii_case(name)) { - return None; + // Empty input: reset needle to show default ranked list (2.1). + // Consumed so prompt+buttons refresh, not stale filter. + if name.is_empty() { + self.picker.set_needle(String::new()); + return Some(CmdResult::NeedPoint); } - self.step = Step::Point { - name: name.to_string(), - }; + // Exact block name (case-insensitive) → accept and go to point step. + // This path is used both for typed exact names and for CmdOption + // button clicks (Message::CommandOptionPick feeds the keyword through + // on_text_input). Returning NeedPoint keeps the prompt updated. + if let Some(canonical) = self.picker.contains_name(name) { + self.step = Step::Point { name: canonical }; + return Some(CmdResult::NeedPoint); + } + // Not an exact match → treat as incremental search needle. + // Update filter and re-render prompt + option buttons. Must return + // Some(NeedPoint) (consumed) not None, otherwise the driver would + // offer the same text to the command a second time. + self.picker.set_needle(name.to_string()); Some(CmdResult::NeedPoint) } Step::FillAttr { .. } => Some(self.accept_attr_value(text)), @@ -268,12 +343,16 @@ impl CadCommand for InsertBlockCommand { } } - fn attreq_set_attdefs(&mut self, attdefs: Vec) { + fn attreq_set_attdefs( + &mut self, + attdefs: Vec, + ) -> Option { self.step = Step::FillAttr { attdefs, idx: 0, values: vec![], }; + self.advance_automatic_attributes() } fn attreq_take_insert(&mut self) -> Option { @@ -284,13 +363,13 @@ impl CadCommand for InsertBlockCommand { } impl InsertBlockCommand { - /// Accept the current attribute value (empty = use default) and advance. - /// Returns CommitAndExit when all attdefs have been filled. fn accept_attr_value(&mut self, text: &str) -> CmdResult { - let (tag, default, next_idx, total) = match &self.step { + let (attdef_idx, default) = match &self.step { Step::FillAttr { attdefs, idx, .. } => { - let ad = &attdefs[*idx]; - (ad.tag.clone(), ad.default_value.clone(), idx + 1, attdefs.len()) + let Some(ad) = attdefs.get(*idx) else { + return CmdResult::Cancel; + }; + (*idx, ad.default_value.clone()) } _ => return CmdResult::Cancel, }; @@ -307,37 +386,58 @@ impl InsertBlockCommand { .. } = self.step { - values.push((tag, value)); - *idx = next_idx; + values.push((attdef_idx, value)); + *idx = attdef_idx + 1; } - if next_idx >= total { - // All attdefs filled — build the INSERT's attribute list. Each - // attribute inherits its ATTDEF's geometry (position, alignment, - // height, rotation, style) via `from_definition`, then the block's - // insertion transform places it into WCS so it lands where the block - // put it instead of stacking at the origin (#255). - let (attdefs, values) = match &self.step { - Step::FillAttr { - attdefs, values, .. - } => (attdefs.clone(), values.clone()), - _ => (vec![], vec![]), - }; - let mut ins = match self.pending_insert.take() { - Some(i) => i, - None => return CmdResult::Cancel, - }; - let xform = ins.get_transform(); - for (ad, (_tag, value)) in attdefs.iter().zip(values.iter()) { - let mut attr = AttributeEntity::from_definition(ad, Some(value.clone())); - attr.apply_transform(&xform); - ins.attributes.push(attr); - } - CmdResult::CommitAndExit(EntityType::Insert(ins)) - } else { - CmdResult::NeedPoint + match self.advance_automatic_attributes() { + Some(entity) => CmdResult::CommitAndExit(entity), + None => CmdResult::NeedPoint, } } + + fn advance_automatic_attributes(&mut self) -> Option { + loop { + let next = match &self.step { + Step::FillAttr { attdefs, idx, .. } => attdefs.get(*idx).map(|ad| { + (*idx, ad.flags.constant, ad.flags.preset, ad.default_value.clone()) + }), + _ => return None, + }; + let Some((attdef_idx, constant, preset, default)) = next else { + return self.finish_insert(); + }; + if !constant && !preset { + return None; + } + if let Step::FillAttr { idx, values, .. } = &mut self.step { + if !constant { + values.push((attdef_idx, default)); + } + *idx += 1; + } + } + } + + fn finish_insert(&mut self) -> Option { + let (attdefs, values) = match &self.step { + Step::FillAttr { + attdefs, values, .. + } => (attdefs.clone(), values.clone()), + _ => return None, + }; + let mut insert = self.pending_insert.take()?; + let transform = insert.get_transform(); + for (attdef_idx, value) in values { + let Some(attdef) = attdefs.get(attdef_idx) else { + continue; + }; + let mut attribute = AttributeEntity::from_definition(attdef, Some(value)); + attribute.apply_transform(&transform); + insert.attributes.push(attribute); + } + Some(EntityType::Insert(insert)) + } } diff --git a/src/modules/insert/minsert.rs b/src/modules/insert/minsert.rs index b91de8d9..55927a13 100644 --- a/src/modules/insert/minsert.rs +++ b/src/modules/insert/minsert.rs @@ -60,7 +60,7 @@ impl ParamIdx { } pub struct MinsertCommand { - available: Vec, + picker: crate::modules::insert::picker::BlockPicker, step: Step, /// Collected array parameters (defaults applied as the user Enters through). rows: u16, @@ -71,9 +71,15 @@ pub struct MinsertCommand { } impl MinsertCommand { - pub fn new(available: Vec) -> Self { + pub fn new_with_usage( + available: Vec, + usage_rank: rustc_hash::FxHashMap, + cliprompt_lines: u8, + ) -> Self { + let limit = (cliprompt_lines as usize).clamp(0, crate::modules::insert::picker::MAX_SUGGESTIONS); + let picker = crate::modules::insert::picker::BlockPicker::new(available, usage_rank, limit); Self { - available, + picker, step: Step::Name, rows: 1, columns: 1, @@ -122,12 +128,25 @@ impl CadCommand for MinsertCommand { fn prompt(&self) -> String { match &self.step { Step::Name => { - let hint = if self.available.is_empty() { - String::new() + if self.picker.is_empty() { + return t!("MINSERT Enter block name:").into_owned(); + } + let needle = self.picker.needle(); + let filtered = self.picker.filtered(); + if !needle.is_empty() && filtered.is_empty() { + return t!("MINSERT No matching blocks for \"%{needle}\"", needle = needle).into_owned(); + } + if needle.is_empty() { + let total = self.picker.total(); + let shown = filtered.len(); + if total <= shown { + t!("MINSERT Enter block name:").into_owned() + } else { + t!("MINSERT Enter block name: [%{shown} of %{total} — type to search]", shown = shown, total = total).into_owned() + } } else { - format!(" [{}]", self.available.join(", ")) - }; - t!("MINSERT Enter block name:%{hint}", hint = hint).into_owned() + t!("MINSERT Enter block name: \"%{needle}\" [%{shown} matches]", needle = needle, shown = filtered.len()).into_owned() + } } Step::Point { name } => { t!( @@ -193,6 +212,25 @@ impl CadCommand for MinsertCommand { } } + fn options(&self) -> Vec { + match &self.step { + Step::Name => self.picker.filtered().iter().map(|n| crate::command::CmdOption::new(n, n)).collect(), + _ => Vec::new(), + } + } + + fn on_live_input(&mut self, input: &str) -> bool { + if !matches!(self.step, Step::Name) { + return false; + } + let needle = input.trim(); + if needle == self.picker.needle() { + return false; + } + self.picker.set_needle(needle.to_string()); + true + } + fn wants_text_input(&self) -> bool { matches!(self.step, Step::Name | Step::Params { .. }) } @@ -201,13 +239,15 @@ impl CadCommand for MinsertCommand { match &self.step { Step::Name => { let typed = text.trim(); - let matched = self - .available - .iter() - .find(|c| c.eq_ignore_ascii_case(typed))?; - self.step = Step::Point { - name: matched.clone(), - }; + if typed.is_empty() { + self.picker.set_needle(String::new()); + return Some(CmdResult::NeedPoint); + } + if let Some(matched) = self.picker.contains_name(typed) { + self.step = Step::Point { name: matched }; + return Some(CmdResult::NeedPoint); + } + self.picker.set_needle(typed.to_string()); Some(CmdResult::NeedPoint) } Step::Point { .. } => None, diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index 81d6c68b..d3b01ff9 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -11,6 +11,7 @@ mod design_center; mod edit_block; pub(crate) mod insert_block; mod landxml; +pub(crate) mod picker; pub mod minsert; mod mview_block; mod open_obj; diff --git a/src/modules/insert/picker.rs b/src/modules/insert/picker.rs new file mode 100644 index 00000000..bc141be4 --- /dev/null +++ b/src/modules/insert/picker.rs @@ -0,0 +1,232 @@ +//! Reusable block-name picker for INSERT/MINSERT interactive search. +//! +//! Encapsulates the full list, lower/upper caches, usage ranking, +//! current needle and filtered view. Centralizes the O(n) scan + +//! partial-sort ranking so both commands share one optimized path +//! and future pickers can reuse it. + +use rustc_hash::FxHashMap; + +/// Maximum suggestion buttons, aligned with `CLIPROMPTLINES` range (0-50). +/// The actual limit is `CLIPROMPTLINES` — number of options is in direct +/// relation to allowed prompt lines, per spec. This constant is the hard cap +/// for performance (widget tree) and matches the sysvar max. +pub const MAX_SUGGESTIONS: usize = 50; + +#[derive(Debug, Clone)] +pub struct BlockPicker { + available: Vec, + lower_cache: Vec, + upper_cache: Vec, + filtered: Vec, + needle: String, + limit: usize, + usage_rank: FxHashMap, +} + +impl BlockPicker { + /// Create a picker. `available` is the full block name list (unsorted). + /// `usage_rank` is `UPPER -> (freq, mru_idx)`. `limit` is capped to + /// `MAX_SUGGESTIONS` by caller (or passed as `MAX_SUGGESTIONS` directly). + pub fn new(mut available: Vec, usage_rank: FxHashMap, limit: usize) -> Self { + let limit = limit.clamp(0, MAX_SUGGESTIONS); + // Rank `available` for the empty-needle view. Use partial sort when + // the list is large to avoid O(n log n) when we only need top `limit`. + if !usage_rank.is_empty() && available.len() > limit && limit > 0 { + // Build scored indices and select top `limit` before full sort. + let mut indices: Vec = (0..available.len()).collect(); + indices.select_nth_unstable_by(limit - 1, |&a, &b| { + let up_a = available[a].to_ascii_uppercase(); + let up_b = available[b].to_ascii_uppercase(); + let (fa, ma) = usage_rank.get(&up_a).copied().unwrap_or((0, usize::MAX)); + let (fb, mb) = usage_rank.get(&up_b).copied().unwrap_or((0, usize::MAX)); + // higher freq first, smaller mru first, then alpha + fb.cmp(&fa).then_with(|| ma.cmp(&mb)).then_with(|| available[a].cmp(&available[b])) + }); + indices.truncate(limit); + indices.sort_by(|&a, &b| { + let up_a = available[a].to_ascii_uppercase(); + let up_b = available[b].to_ascii_uppercase(); + let (fa, ma) = usage_rank.get(&up_a).copied().unwrap_or((0, usize::MAX)); + let (fb, mb) = usage_rank.get(&up_b).copied().unwrap_or((0, usize::MAX)); + fb.cmp(&fa).then_with(|| ma.cmp(&mb)).then_with(|| available[a].cmp(&available[b])) + }); + let mut ranked: Vec = indices.into_iter().map(|i| available[i].clone()).collect(); + // Append the rest unsorted — they are only needed when filtering. + // To keep `available` complete for filtering, retain all names but + // move the ranked top to front. Simpler: sort fully if limit==available.len(). + // For large n, we still want the rest available for substring search. + // So rebuild: ranked top + remaining in arbitrary order. + let mut remaining: Vec = available.into_iter().filter(|name| !ranked.contains(name)).collect(); + ranked.append(&mut remaining); + available = ranked; + } else if available.len() > 1 { + // Small n or no usage_rank: full sort is cheap and gives deterministic empty-needle order. + if !usage_rank.is_empty() { + available.sort_by(|a, b| { + let up_a = a.to_ascii_uppercase(); + let up_b = b.to_ascii_uppercase(); + let (fa, ma) = usage_rank.get(&up_a).copied().unwrap_or((0, usize::MAX)); + let (fb, mb) = usage_rank.get(&up_b).copied().unwrap_or((0, usize::MAX)); + fb.cmp(&fa).then_with(|| ma.cmp(&mb)).then_with(|| a.cmp(b)) + }); + } else { + available.sort(); + } + } + + let lower_cache: Vec = available.iter().map(|s| s.to_ascii_lowercase()).collect(); + let upper_cache: Vec = available.iter().map(|s| s.to_ascii_uppercase()).collect(); + let filtered = Self::filter_ranked_inner(&available, &lower_cache, &upper_cache, "", &usage_rank, limit); + Self { + available, + lower_cache, + upper_cache, + filtered, + needle: String::new(), + limit, + usage_rank, + } + } + + /// Current needle (as typed, trimmed). + pub fn needle(&self) -> &str { + &self.needle + } + + /// Filtered view (≤ limit). + pub fn filtered(&self) -> &[String] { + &self.filtered + } + + /// Full list length. + pub fn total(&self) -> usize { + self.available.len() + } + + /// Whether the full list is empty. + pub fn is_empty(&self) -> bool { + self.available.is_empty() + } + + /// Does `available` contain `name` case-insensitively? + pub fn contains_name(&self, name: &str) -> Option { + self.available.iter().find(|c| c.eq_ignore_ascii_case(name)).cloned() + } + + /// Update needle and recompute `filtered`. Pass trimmed `needle`. + /// Handles empty string as reset to default ranked view. + pub fn set_needle(&mut self, needle: String) { + self.needle = needle; + let needle_ref = self.needle.as_str(); + self.filtered = Self::filter_ranked_inner( + &self.available, + &self.lower_cache, + &self.upper_cache, + needle_ref, + &self.usage_rank, + self.limit, + ); + } + + /// Test helper: simulate typing without exact-match handling. + #[cfg(test)] + pub fn set_needle_for_test(&mut self, needle: &str) { + self.set_needle(needle.to_string()); + } + + fn filter_ranked_inner( + available: &[String], + lower_cache: &[String], + upper_cache: &[String], + needle: &str, + usage_rank: &FxHashMap, + limit: usize, + ) -> Vec { + if limit == 0 { + return Vec::new(); + } + if needle.is_empty() { + return available.iter().take(limit).cloned().collect(); + } + let n = needle.to_ascii_lowercase(); + let mut scored: Vec<(usize, u8, i32, usize)> = Vec::new(); + scored.reserve(available.len().min(limit * 4 + 16)); + for (i, lc) in lower_cache.iter().enumerate() { + if let Some(pos) = lc.find(n.as_str()) { + // Use upper_cache to avoid allocating per block per keystroke (1.1). + let up = &upper_cache[i]; + let (freq, mru) = usage_rank.get(up).copied().unwrap_or((0, usize::MAX)); + let prefix = if pos == 0 { 0 } else { 1 }; + scored.push((i, prefix, -(freq as i32), mru)); + } + } + if scored.is_empty() { + return Vec::new(); + } + // Partial sort: only need top `limit` (1.2). + let comparator = |a: &(usize, u8, i32, usize), b: &(usize, u8, i32, usize)| { + a.1.cmp(&b.1) + .then_with(|| a.2.cmp(&b.2)) + .then_with(|| a.3.cmp(&b.3)) + .then_with(|| available[a.0].cmp(&available[b.0])) + }; + if scored.len() > limit { + scored.select_nth_unstable_by(limit - 1, comparator); + scored.truncate(limit); + } + scored.sort_by(comparator); + scored.into_iter().map(|(i, _, _, _)| available[i].clone()).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustc_hash::FxHashMap; + + fn picker(names: &[&str], usage: Vec<(&str, u32, usize)>, limit: usize) -> BlockPicker { + let mut map = FxHashMap::default(); + for (name, freq, idx) in usage { map.insert(name.to_ascii_uppercase(), (freq, idx)); } + BlockPicker::new(names.iter().map(|s| s.to_string()).collect(), map, limit) + } + + #[test] + fn empty_needle_shows_ranked_top() { + let mut p = picker(&["Gamma","Alpha","Beta"], vec![("Beta",5,0),("Alpha",2,1)], 2); + assert_eq!(p.filtered(), &["Beta","Alpha"]); + p.set_needle("".into()); + assert_eq!(p.filtered(), &["Beta","Alpha"]); + } + + #[test] + fn substring_filter_prefix_first() { + let mut p = picker(&["Alpha","Alphabet","Beta","Alpine"], vec![], 8); + p.set_needle("Al".into()); + let f = p.filtered(); + assert!(f.iter().all(|n| n.to_lowercase().contains("al"))); + // prefix matches before substring — but all four contain Al at pos 0 except maybe edge, so alphabetical + assert_eq!(f.len(), 3); + } + + #[test] + fn empty_input_resets_to_default() { + let mut p = picker(&["A","B","C"], vec![], 2); + p.set_needle("B".into()); + assert_eq!(p.filtered(), &["B"]); + p.set_needle("".into()); + assert_eq!(p.filtered().len(), 2); + } + + #[test] + fn upper_cache_avoids_allocation_and_partial_sort_limits() { + let names: Vec = (0..1000).map(|i| format!("Block_{:04}", i)).collect(); + let mut p = BlockPicker::new(names, FxHashMap::default(), 8); + let start = std::time::Instant::now(); + for needle in ["B","Bl","Block_0","Block_00","a"] { + p.set_needle(needle.into()); + assert!(p.filtered().len() <= 8); + } + assert!(start.elapsed().as_millis() < 20, "too slow"); + } +} diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 95c9a619..db2898da 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -1,27 +1,19 @@ -// 2D→3D modelling commands — EXTRUDE, REVOLVE, SWEEP, LOFT. -// -// Each picks profile/path entities and emits a `CmdResult` whose handler -// builds a solid and inserts its MeshModel into scene.meshes under a -// minimal placeholder Solid3D entity (empty ACIS — saving will not restore -// the mesh). The standalone primitives (BOX/CYLINDER/CONE/SPHERE/WEDGE/TORUS) -// live in the Model tab (`modules::model::primitive_cmd`). +// Profile-based kernel solid commands stored as exact ACIS data. use acadrust::{entities::Solid3D, EntityType}; use glam::DVec3; -use crate::t; use crate::command::{CadCommand, CmdResult}; - -// BOX / SPHERE / CYLINDER (and CONE / WEDGE / TORUS) now live in the Model tab -// (`modules::model::primitive_cmd`), which builds them as B-reps cached -// for the Design-group boolean tools. EXTRUDE / REVOLVE / SWEEP / LOFT remain -// here as 2D→3D operations. +use crate::t; // ── EXTRUDE command ──────────────────────────────────────────────────────── pub struct ExtrudeCommand { + command_name: &'static str, step: ExtrudeStep, pub target_handle: acadrust::Handle, + anchor: DVec3, + direction: Option, color: [f32; 4], } @@ -32,10 +24,16 @@ enum ExtrudeStep { } impl ExtrudeCommand { - pub fn new(color: [f32; 4]) -> Self { + pub fn new_named(name: &str, color: [f32; 4]) -> Self { Self { + command_name: match name { + "THICKEN" => "THICKEN", + _ => "EXTRUDE", + }, step: ExtrudeStep::Pick, target_handle: acadrust::Handle::NULL, + anchor: DVec3::ZERO, + direction: None, color, } } @@ -43,7 +41,7 @@ impl ExtrudeCommand { impl CadCommand for ExtrudeCommand { fn name(&self) -> &'static str { - "EXTRUDE" + self.command_name } fn prompt(&self) -> String { match self.step { @@ -55,19 +53,33 @@ impl CadCommand for ExtrudeCommand { fn needs_entity_pick(&self) -> bool { self.step == ExtrudeStep::Pick } - fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: DVec3) -> CmdResult { + fn entity_pick_uses_surface_point(&self) -> bool { + true + } + fn set_entity_pick_direction(&mut self, direction: Option) { + self.direction = direction.and_then(DVec3::try_normalize); + } + fn on_entity_pick(&mut self, handle: acadrust::Handle, point: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; } self.target_handle = handle; + self.anchor = point; self.step = ExtrudeStep::Height; CmdResult::NeedPoint } fn on_point(&mut self, pt: DVec3) -> CmdResult { if self.step == ExtrudeStep::Height { + let height = self + .direction + .map(|direction| (pt - self.anchor).dot(direction)) + .unwrap_or_else(|| pt.distance(self.anchor)); + if !height.is_finite() || height.abs() <= 1e-6 { + return CmdResult::NeedPoint; + } return CmdResult::ExtrudeEntity { handle: self.target_handle, - height: pt.y.abs().max(1e-4), + height, color: self.color, }; } @@ -81,13 +93,151 @@ impl CadCommand for ExtrudeCommand { .filter(|&h| h.abs() > 1e-6) .map(|h| CmdResult::ExtrudeEntity { handle: self.target_handle, - height: h.abs(), + height: h, color: self.color, }) } fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } + fn cursor_axis(&self) -> Option<(DVec3, DVec3)> { + (self.step == ExtrudeStep::Height).then_some((self.anchor, self.direction?)) + } + fn dyn_spec(&self) -> Option { + (self.step == ExtrudeStep::Height).then_some(crate::command::DynSpec { + anchor: crate::command::DynAnchor::Point(self.anchor), + fields: vec![crate::command::DynFieldSpec::new( + crate::command::DynRole::Distance, + )], + guide: crate::command::DynGuide::Radius, + ref_point: None, + }) + } + fn dyn_live_value(&self, cursor: DVec3) -> Option { + Some((cursor - self.anchor).dot(self.direction?)) + } +} + +// ── PRESSPULL command ───────────────────────────────────────────────────── + +pub struct PresspullCommand { + picked: Option<(acadrust::Handle, DVec3)>, + direction: Option, + color: [f32; 4], +} + +impl PresspullCommand { + pub fn new(color: [f32; 4]) -> Self { + Self { + picked: None, + direction: None, + color, + } + } +} + +impl CadCommand for PresspullCommand { + fn name(&self) -> &'static str { + "PRESSPULL" + } + + fn prompt(&self) -> String { + if self.picked.is_none() { + t!("PRESSPULL Select a closed profile or planar solid face:").into_owned() + } else { + t!("PRESSPULL Signed distance:").into_owned() + } + } + + fn needs_entity_pick(&self) -> bool { + self.picked.is_none() + } + + fn entity_pick_includes_fills(&self) -> bool { + true + } + + fn entity_pick_uses_surface_point(&self) -> bool { + true + } + + fn set_entity_pick_direction(&mut self, direction: Option) { + self.direction = direction.and_then(|value| value.try_normalize()); + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: acadrust::Handle, point: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + self.picked = Some((handle, point)); + CmdResult::NeedPoint + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + let Some((handle, pick)) = self.picked else { + return CmdResult::NeedPoint; + }; + let distance = self + .direction + .map(|direction| (point - pick).dot(direction)) + .unwrap_or_else(|| point.distance(pick)); + if !distance.is_finite() || distance.abs() <= 1e-6 { + return CmdResult::NeedPoint; + } + CmdResult::PresspullEntity { + handle, + pick, + distance, + drag: Some(point), + color: self.color, + } + } + + fn wants_text_input(&self) -> bool { + self.picked.is_some() + } + + fn on_text_input(&mut self, text: &str) -> Option { + let (handle, pick) = self.picked?; + crate::entities::common::parse_typed_length(text) + .filter(|distance| distance.abs() > 1e-6) + .map(|distance| CmdResult::PresspullEntity { + handle, + pick, + distance, + drag: None, + color: self.color, + }) + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn cursor_axis(&self) -> Option<(DVec3, DVec3)> { + Some((self.picked?.1, self.direction?)) + } + + fn dyn_spec(&self) -> Option { + let (_, anchor) = self.picked?; + Some(crate::command::DynSpec { + anchor: crate::command::DynAnchor::Point(anchor), + fields: vec![crate::command::DynFieldSpec::new( + crate::command::DynRole::Distance, + )], + guide: crate::command::DynGuide::Radius, + ref_point: None, + }) + } + + fn dyn_live_value(&self, cursor: DVec3) -> Option { + let (_, anchor) = self.picked?; + Some((cursor - anchor).dot(self.direction?)) + } } // ── REVOLVE command ──────────────────────────────────────────────────────── @@ -171,7 +321,7 @@ impl CadCommand for RevolveCommand { .ok() .filter(|&a| a.abs() > 1e-3)? }; - Some(self.make_revolve(angle.abs())) + Some(self.make_revolve(angle)) } fn on_enter(&mut self) -> CmdResult { if self.step == RevolveStep::Angle { @@ -323,16 +473,16 @@ impl CadCommand for LoftCommand { } } -// ── Placeholder Solid3D entity construction ──────────────────────────────── +// ── Solid3D entity construction ──────────────────────────────────────────── -/// Create a minimal Solid3D entity with empty ACIS data (placeholder only). pub fn empty_solid3d() -> EntityType { EntityType::Solid3D(Solid3D::new()) } - // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["EXTRUDE"] }); // ExtrudeCommand -inventory::submit!(crate::command::CommandRegistration { names: &["LOFT"] }); // LoftCommand -inventory::submit!(crate::command::CommandRegistration { names: &["REVOLVE"] }); // RevolveCommand -inventory::submit!(crate::command::CommandRegistration { names: &["SWEEP"] }); // SweepCommand +inventory::submit!(crate::command::CommandRegistration { + names: &["EXTRUDE", "THICKEN", "PRESSPULL"] +}); +inventory::submit!(crate::command::CommandRegistration { names: &["LOFT"] }); +inventory::submit!(crate::command::CommandRegistration { names: &["REVOLVE"] }); +inventory::submit!(crate::command::CommandRegistration { names: &["SWEEP"] }); diff --git a/src/modules/model/boolean_cmd.rs b/src/modules/model/boolean_cmd.rs index 94d926f1..ff607b81 100644 --- a/src/modules/model/boolean_cmd.rs +++ b/src/modules/model/boolean_cmd.rs @@ -1,8 +1,9 @@ -// Boolean operations on 3D solids (Design group). The actual CSG runs in -// `App::solid_boolean` (src/app/model_ops.rs) using the kernel on the -// session-cached B-reps; this module just names the operations. +use acadrust::{EntityType, Handle}; +use glam::DVec3; -/// Which boolean a Design-group tool performs on the two selected solids. +use crate::command::{CadCommand, CmdResult, SelectionEntity}; + +/// Which boolean operation to apply to selected solids. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum BoolOp { Union, @@ -21,6 +22,86 @@ impl BoolOp { } } +enum SubtractStep { + Bases, + Cutters, +} + +pub struct SubtractCommand { + step: SubtractStep, + bases: Vec, + selected: Vec, +} + +impl SubtractCommand { + pub fn new(bases: Vec) -> Self { + let step = if bases.is_empty() { + SubtractStep::Bases + } else { + SubtractStep::Cutters + }; + Self { + step, + bases, + selected: Vec::new(), + } + } +} + +impl CadCommand for SubtractCommand { + fn name(&self) -> &'static str { + "SUBTRACT" + } + + fn prompt(&self) -> String { + match self.step { + SubtractStep::Bases => crate::t!("SUBTRACT Select base solids, then press Enter:") + .into_owned(), + SubtractStep::Cutters => { + crate::t!("SUBTRACT Select solids to subtract, then press Enter:").into_owned() + } + } + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + if self.selected.is_empty() { + return CmdResult::NeedPoint; + } + match self.step { + SubtractStep::Bases => { + self.bases = std::mem::take(&mut self.selected); + self.step = SubtractStep::Cutters; + CmdResult::DeselectAndContinue + } + SubtractStep::Cutters => CmdResult::SolidSubtract { + bases: std::mem::take(&mut self.bases), + cutters: std::mem::take(&mut self.selected), + }, + } + } + + fn is_selection_gathering(&self) -> bool { + true + } + + fn inject_selection_entities(&mut self, entities: Vec) { + self.selected = entities + .into_iter() + .filter_map(|entity| { + matches!(entity.entity, EntityType::Solid3D(_)).then_some(entity.handle) + }) + .collect(); + } + + fn on_selection_complete(&mut self, _handles: Vec) -> CmdResult { + CmdResult::NeedPoint + } +} + // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["UNION", "SUBTRACT", "INTERSECT"] diff --git a/src/modules/model/edge_cmd.rs b/src/modules/model/edge_cmd.rs new file mode 100644 index 00000000..65ee2d31 --- /dev/null +++ b/src/modules/model/edge_cmd.rs @@ -0,0 +1,146 @@ +use acadrust::Handle; +use glam::DVec3; + +use crate::command::{ + CadCommand, CmdResult, DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec, +}; + +#[derive(Clone, Copy)] +pub enum EdgeOperation { + Fillet, + Chamfer, +} + +pub struct SolidEdgeCommand { + operation: EdgeOperation, + target: Option, + picked: Option<(Handle, DVec3)>, + default_value: f64, +} + +impl SolidEdgeCommand { + pub fn new(operation: EdgeOperation, target: Option) -> Self { + Self { + operation, + target, + picked: None, + default_value: 1.0, + } + } + + fn finish(&self, value: f64) -> CmdResult { + let Some((handle, pick)) = self.picked else { + return CmdResult::Cancel; + }; + CmdResult::SolidEdgeBlend { + handle, + pick, + value, + fillet: matches!(self.operation, EdgeOperation::Fillet), + } + } +} + +impl CadCommand for SolidEdgeCommand { + fn name(&self) -> &'static str { + match self.operation { + EdgeOperation::Fillet => "SOLIDFILLET", + EdgeOperation::Chamfer => "SOLIDCHAMFER", + } + } + + fn prompt(&self) -> String { + if self.picked.is_none() { + return crate::t!("Select a solid edge:").into_owned(); + } + match self.operation { + EdgeOperation::Fillet => { + crate::tf!("Specify fillet radius <{:.3}>:", self.default_value).into_owned() + } + EdgeOperation::Chamfer => { + crate::tf!("Specify chamfer distance <{:.3}>:", self.default_value).into_owned() + } + } + } + + fn needs_entity_pick(&self) -> bool { + self.picked.is_none() + } + + fn entity_pick_includes_fills(&self) -> bool { + true + } + + fn entity_pick_uses_surface_point(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + if handle.is_null() || self.target.is_some_and(|target| target != handle) { + return CmdResult::NeedPoint; + } + self.picked = Some((handle, point)); + CmdResult::NeedPoint + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + let Some((_, pick)) = self.picked else { + return CmdResult::NeedPoint; + }; + let value = point.distance(pick); + if value > 0.0 && value.is_finite() { + self.finish(value) + } else { + CmdResult::NeedPoint + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.picked.is_none() { + return None; + } + let value = text.trim().parse::().ok()?; + (value > 0.0 && value.is_finite()).then(|| self.finish(value)) + } + + fn on_enter(&mut self) -> CmdResult { + if self.picked.is_some() { + self.finish(self.default_value) + } else { + CmdResult::Cancel + } + } + + fn wants_text_input(&self) -> bool { + self.picked.is_some() + } + + fn dyn_commit_as_text(&self) -> bool { + self.picked.is_some() + } + + fn dyn_spec(&self) -> Option { + let (_, pick) = self.picked?; + Some(DynSpec { + anchor: DynAnchor::Point(pick), + fields: vec![DynFieldSpec::new(match self.operation { + EdgeOperation::Fillet => DynRole::Radius, + EdgeOperation::Chamfer => DynRole::Distance, + })], + guide: DynGuide::Radius, + ref_point: None, + }) + } + + fn dyn_live_value(&self, cursor: DVec3) -> Option { + self.picked.map(|(_, pick)| cursor.distance(pick)) + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["SOLIDFILLET", "SOLIDCHAMFER"] +}); diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index 08f6f6f5..5272161a 100644 --- a/src/modules/model/mod.rs +++ b/src/modules/model/mod.rs @@ -1,11 +1,7 @@ -// Model module — 3D solid modelling. -// -// Model group : create primitive solids (box, cylinder, cone, sphere, …) -// as ACIS Solid3D entities via acadrust's primitive builders. -// Design group : combine solids with the kernel boolean operations -// (union / subtract / intersect). +// Solid creation and kernel modelling tools. pub mod boolean_cmd; +pub mod edge_cmd; pub mod primitive_cmd; use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; @@ -16,11 +12,20 @@ const BOX_ICON: &[u8] = include_bytes!("../../../assets/icons/box3d.svg"); const CYLINDER_ICON: &[u8] = include_bytes!("../../../assets/icons/cylinder3d.svg"); const CONE_ICON: &[u8] = include_bytes!("../../../assets/icons/cone3d.svg"); const SPHERE_ICON: &[u8] = include_bytes!("../../../assets/icons/sphere3d.svg"); +const PYRAMID_ICON: &[u8] = include_bytes!("../../../assets/icons/pyramid3d.svg"); const WEDGE_ICON: &[u8] = include_bytes!("../../../assets/icons/wedge3d.svg"); const TORUS_ICON: &[u8] = include_bytes!("../../../assets/icons/torus3d.svg"); +const POLYSOLID_ICON: &[u8] = include_bytes!("../../../assets/icons/polysolid.svg"); +const EXTRUDE_ICON: &[u8] = include_bytes!("../../../assets/icons/extrude.svg"); +const REVOLVE_ICON: &[u8] = include_bytes!("../../../assets/icons/revolve.svg"); +const LOFT_ICON: &[u8] = include_bytes!("../../../assets/icons/loft.svg"); +const SWEEP_ICON: &[u8] = include_bytes!("../../../assets/icons/sweep.svg"); +const PRESSPULL_ICON: &[u8] = include_bytes!("../../../assets/icons/presspull.svg"); const UNION_ICON: &[u8] = include_bytes!("../../../assets/icons/union.svg"); const SUBTRACT_ICON: &[u8] = include_bytes!("../../../assets/icons/subtract.svg"); const INTERSECT_ICON: &[u8] = include_bytes!("../../../assets/icons/intersect.svg"); +const FILLET_ICON: &[u8] = include_bytes!("../../../assets/icons/fillet.svg"); +const CHAMFER_ICON: &[u8] = include_bytes!("../../../assets/icons/chamfer.svg"); /// Helper to declare a ribbon tool that fires a named command. fn tool(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef { @@ -37,7 +42,7 @@ impl CadModule for ModelModule { "model" } fn title(&self) -> &'static str { - "Model" + "Modelling" } fn ribbon_groups(&self) -> &[RibbonGroup] { @@ -45,31 +50,46 @@ impl CadModule for ModelModule { GROUPS.get_or_init(|| { vec![ RibbonGroup { - title: "Model", + title: "Create", tools: vec![ - RibbonItem::LargeTool(tool("BOX", "Box", BOX_ICON)), - RibbonItem::LargeTool(tool("CYLINDER", "Cylinder", CYLINDER_ICON)), - RibbonItem::LargeTool(tool("CONE", "Cone", CONE_ICON)), - RibbonItem::LargeTool(tool("SPHERE", "Sphere", SPHERE_ICON)), - RibbonItem::Dropdown { - id: "MODEL_MORE", - icon: IconKind::Svg(WEDGE_ICON), + RibbonItem::LargeDropdown { + id: "MODEL_PRIMITIVES", + label: "Box", + icon: IconKind::Svg(BOX_ICON), items: vec![ + ("BOX", "Box", IconKind::Svg(BOX_ICON)), + ("CYLINDER", "Cylinder", IconKind::Svg(CYLINDER_ICON)), + ("CONE", "Cone", IconKind::Svg(CONE_ICON)), + ("SPHERE", "Sphere", IconKind::Svg(SPHERE_ICON)), + ("PYRAMID", "Pyramid", IconKind::Svg(PYRAMID_ICON)), ("WEDGE", "Wedge", IconKind::Svg(WEDGE_ICON)), ("TORUS", "Torus", IconKind::Svg(TORUS_ICON)), + ("POLYSOLID", "Polysolid", IconKind::Svg(POLYSOLID_ICON)), ], - default: "WEDGE", + default: "BOX", }, + RibbonItem::LargeTool(tool("EXTRUDE", "Extrude", EXTRUDE_ICON)), + RibbonItem::LargeTool(tool("REVOLVE", "Revolve", REVOLVE_ICON)), + RibbonItem::LargeTool(tool("LOFT", "Loft", LOFT_ICON)), + RibbonItem::LargeTool(tool("SWEEP", "Sweep", SWEEP_ICON)), + RibbonItem::LargeTool(tool("PRESSPULL", "Presspull", PRESSPULL_ICON)), ], }, RibbonGroup { - title: "Design", + title: "Boolean", tools: vec![ RibbonItem::LargeTool(tool("UNION", "Union", UNION_ICON)), RibbonItem::LargeTool(tool("SUBTRACT", "Subtract", SUBTRACT_ICON)), RibbonItem::LargeTool(tool("INTERSECT", "Intersect", INTERSECT_ICON)), ], }, + RibbonGroup { + title: "Edges", + tools: vec![ + RibbonItem::LargeTool(tool("SOLIDFILLET", "Fillet", FILLET_ICON)), + RibbonItem::LargeTool(tool("SOLIDCHAMFER", "Chamfer", CHAMFER_ICON)), + ], + }, ] }) } diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index 66941b70..b7dc9d25 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -1,22 +1,15 @@ -// 3D primitive creation — BOX / CYLINDER / CONE / SPHERE / WEDGE / PYRAMID / -// TORUS. Each is placed CAD-style with a few clicks (planar footprint first, -// then a height value), then built as a real ACIS `Solid3D` via acadrust's -// `acis::primitives` builders. `Scene::add_entity` tessellates the SAT B-rep -// into the 3D mesh pipeline, so the solid renders, selects, and saves to DXF. -// -// A matching the kernel `Solid` is cached on the scene (see model/mod.rs) when the -// entity is committed, so the Design-group boolean tools can combine it. +// Interactive kernel primitives stored as ACIS Solid3D entities. use acadrust::entities::Solid3D; use acadrust::objects::SolidHistoryOperation; -use acadrust::{primitives, EntityType}; -use glam::DVec3; -use crate::t; +use acadrust::EntityType; use cadkernel::brep::Body; +use glam::DVec3; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::scene::model::solid_model; use crate::scene::model::wire_model::WireModel; +use crate::t; /// Which primitive a `PrimitiveCommand` builds. #[derive(Clone, Copy, PartialEq, Eq)] @@ -26,6 +19,7 @@ pub enum Shape { Cylinder, Cone, Sphere, + Pyramid, Torus, } @@ -37,6 +31,7 @@ impl Shape { "CYLINDER" => Shape::Cylinder, "CONE" => Shape::Cone, "SPHERE" => Shape::Sphere, + "PYRAMID" | "PYR" => Shape::Pyramid, "TORUS" => Shape::Torus, _ => return None, }) @@ -48,6 +43,7 @@ impl Shape { Shape::Cylinder => "CYLINDER", Shape::Cone => "CONE", Shape::Sphere => "SPHERE", + Shape::Pyramid => "PYRAMID", Shape::Torus => "TORUS", } } @@ -129,12 +125,11 @@ impl PrimitiveCommand { .to_cols_array() } - /// Build both the acadrust `Solid3D` (ACIS, for persistence) and the - /// kernel `Body` (rendering + booleans) from the footprint + `height`. - fn build(&self, height: f64) -> Option<(EntityType, Body, SolidHistoryOperation)> { + /// Build the persistent kernel body and its history node. + fn build(&self, height: f64) -> Option<(Body, SolidHistoryOperation)> { use crate::scene::model::solid_history; - let (doc, solid, history) = match self.shape { + let (solid, history) = match self.shape { Shape::Box | Shape::Wedge => { let (a, b) = (self.pts[0], self.pts[1]); let length = (b.x - a.x).abs(); @@ -150,7 +145,6 @@ impl PrimitiveCommand { a.z + height / 2.0, ]; ( - primitives::build_box(center, length, width, height), solid_model::box_solid(center, length, width, height), solid_history::box_op( self.history_transform(origin), @@ -162,7 +156,6 @@ impl PrimitiveCommand { } else { let origin = [a.x.min(b.x), a.y.min(b.y), a.z]; ( - primitives::build_wedge(origin, length, width, height), solid_model::wedge_solid(origin, length, width, height), solid_history::wedge_op( self.history_transform(DVec3::from_array(origin)), @@ -182,7 +175,6 @@ impl PrimitiveCommand { let center = [c.x, c.y, c.z]; if self.shape == Shape::Cylinder { ( - primitives::build_cylinder(center, r, height), solid_model::cylinder_solid(center, r, height), solid_history::cylinder_op( self.history_transform(c), @@ -192,7 +184,6 @@ impl PrimitiveCommand { ) } else { ( - primitives::build_cone(center, r, height), solid_model::cone_solid(center, r, height), solid_history::cone_op( self.history_transform(c), @@ -210,11 +201,21 @@ impl PrimitiveCommand { } let center = [c.x, c.y, c.z]; ( - primitives::build_sphere(center, r), solid_model::sphere_solid(center, r), solid_history::sphere_op(self.history_transform(c), r), ) } + Shape::Pyramid => { + let c = self.pts[0]; + let r = (self.pts[1] - c).length(); + if r < 1e-6 || height < 1e-6 { + return None; + } + ( + solid_model::pyramid_solid([c.x, c.y, c.z], r, height, 4), + solid_history::pyramid_op(self.history_transform(c), r, height, 4), + ) + } Shape::Torus => { let c = self.pts[0]; let first = (self.pts[1] - c).length(); @@ -228,7 +229,6 @@ impl PrimitiveCommand { let minor = (outer - inner) * 0.5; let center = [c.x, c.y, c.z]; ( - primitives::build_torus(center, major, minor), solid_model::torus_solid(center, major, minor), solid_history::torus_op( self.history_transform(c), @@ -239,14 +239,12 @@ impl PrimitiveCommand { } }; let solid = solid?; - let mut s3d = Solid3D::new(); - s3d.set_sat_document(&doc); - Some((EntityType::Solid3D(s3d), solid, history)) + Some((solid, history)) } fn commit(&self, height: f64) -> CmdResult { match self.build(height) { - Some((entity, solid, history)) => { + Some((solid, history)) => { // Built upright in its own frame, then put on the working // plane — the same move `place_entity` makes for the ACIS // copy, so the two stay on top of each other. @@ -262,11 +260,21 @@ impl PrimitiveCommand { ], ); match placed { - Some(placed) => CmdResult::CommitSolid { - entity: self.plane.place_entity(entity), - solid: Box::new(placed), - history, - }, + Some(placed) => { + let Some(document) = + crate::scene::convert::acis_export::solid_to_sat(&placed) + else { + return CmdResult::Cancel; + }; + let mut entity = Solid3D::new(); + entity.set_sat_document(&document); + entity.wires = solid_model::edge_wires(&placed); + CmdResult::CommitSolid { + entity: EntityType::Solid3D(entity), + solid: Box::new(placed), + history, + } + } None => CmdResult::Cancel, } } @@ -390,7 +398,15 @@ impl CadCommand for PrimitiveCommand { fn footprint_wire(shape: Shape, pts: &[DVec3]) -> WireModel { let mut points: Vec<[f32; 3]> = Vec::new(); - if shape.radial() { + if shape == Shape::Pyramid { + let center = pts[0]; + let radius = (pts[1] - center).length(); + let corners: [DVec3; 4] = std::array::from_fn(|index| { + let angle = index as f64 * std::f64::consts::FRAC_PI_2; + center + DVec3::new(radius * angle.cos(), radius * angle.sin(), 0.0) + }); + push_loop(&mut points, &corners); + } else if shape.radial() { let c = pts[0]; let r = (pts[1] - c).length(); circle_points(&mut points, c, r); @@ -446,9 +462,21 @@ fn height_wire(shape: Shape, pts: &[DVec3], height: f64) -> WireModel { push_segment(&mut points, low[i], high[i]); } } - Shape::Cylinder | Shape::Cone => { + Shape::Cylinder | Shape::Cone | Shape::Pyramid => { let center = pts[0]; let radius = (pts[1] - center).length(); + if shape == Shape::Pyramid { + let base: [DVec3; 4] = std::array::from_fn(|index| { + let angle = index as f64 * std::f64::consts::FRAC_PI_2; + center + DVec3::new(radius * angle.cos(), radius * angle.sin(), 0.0) + }); + push_loop(&mut points, &base); + let apex = center + DVec3::Z * height; + for corner in base { + push_segment(&mut points, corner, apex); + } + return wire("primitive_height_preview", points); + } push_circle(&mut points, center, radius); if shape == Shape::Cylinder { push_circle(&mut points, center + DVec3::Z * height, radius); @@ -506,13 +534,14 @@ fn circle_points(out: &mut Vec<[f32; 3]>, c: DVec3, r: f64) { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { - names: &["BOX", "WEDGE", "CYLINDER", "CONE", "SPHERE", "TORUS"] + names: &["BOX", "WEDGE", "CYLINDER", "CONE", "SPHERE", "PYRAMID", "PYR", "TORUS"] }); fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel { WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -522,9 +551,9 @@ fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel { render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), name: name.into(), points, points_low: Vec::new(), diff --git a/src/modules/view/plot_window.rs b/src/modules/view/plot_window.rs index 4f86e8cf..03341c08 100644 --- a/src/modules/view/plot_window.rs +++ b/src/modules/view/plot_window.rs @@ -56,6 +56,7 @@ impl CadCommand for PlotWindowCommand { Some(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/modules/view/zoom_window.rs b/src/modules/view/zoom_window.rs index 3e666cc7..a22f030e 100644 --- a/src/modules/view/zoom_window.rs +++ b/src/modules/view/zoom_window.rs @@ -138,6 +138,7 @@ impl CadCommand for ZoomWindowCommand { Some(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/plugin/external.rs b/src/plugin/external.rs index d7df8c48..c395b1e5 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -30,6 +30,12 @@ pub struct RegistryEntry { pub struct ReleaseInfo { pub tag: String, pub api_version: u32, + /// Full `acadrust` git source used by the release. + pub acadrust_source: Option, + /// Whether `[opencad]` declares `acadrust_source`. + pub acadrust_declared: bool, + /// Whether the release matches this host. + pub acadrust_compatible: bool, } /// An add-on package found on disk (not necessarily loaded or compatible). @@ -44,6 +50,10 @@ pub struct ExternalPlugin { /// an older `plugin.toml` does not declare `repository`. pub repository: Option, pub api_version: u32, + /// Full `acadrust` git source used by the plugin. + pub acadrust_source: Option, + /// Whether `[opencad]` declares `acadrust_source`. + pub acadrust_declared: bool, pub ribbon_order: i32, pub command_prefixes: Vec, /// The package directory under the plugins folder. @@ -58,11 +68,27 @@ impl ExternalPlugin { ocs_plugin_api::manifest::host_accepts_plugin_version(self.api_version) } - /// True when the package can be loaded today: compatible API *and* a native - /// library present for this platform. + /// Returns whether the package's dependency fingerprint matches the host. + pub fn acadrust_compatible(&self) -> bool { + if !ocs_plugin_api::version_info::uses_acadrust_gate(self.api_version) { + return true; + } + if !self.acadrust_declared { + return true; + } + match self.acadrust_source.as_deref() { + None | Some("") => false, + Some(source) => ocs_plugin_api::version_info::acadrust_sources_compatible( + source, + ocs_plugin_api::version_info::host_acadrust_source(), + ), + } + } + + /// Returns whether the package can be loaded. #[allow(dead_code)] // plugin-host surface (issue #100); not yet wired pub fn loadable(&self) -> bool { - self.api_compatible() && self.lib_present + self.api_compatible() && self.acadrust_compatible() && self.lib_present } } @@ -176,12 +202,21 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option { let mut description = String::new(); let mut repository = None; let mut api_version: u32 = 0; + let mut acadrust_source: Option = None; + let mut acadrust_declared = false; let mut ribbon_order: i32 = 0; let mut command_prefixes: Vec = Vec::new(); + let mut section = None; for raw in text.lines() { let line = raw.trim(); - if line.is_empty() || line.starts_with('#') || line.starts_with('[') { + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some(header) = line.strip_prefix('[') { + section = header + .find(']') + .map(|end| header[..end].trim()); continue; } let Some((key, value)) = line.split_once('=') else { @@ -196,6 +231,11 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option { "description" => description = unquote(value), "repository" => repository = normalize_repository(&unquote(value)), "api_version" => api_version = value.parse().unwrap_or(0), + "acadrust_source" if section == Some("opencad") => { + acadrust_declared = true; + let v = unquote(value); + acadrust_source = if v.is_empty() { None } else { Some(v) }; + } "ribbon_order" => ribbon_order = value.parse().unwrap_or(0), "command_prefixes" => command_prefixes = parse_string_array(value), _ => {} @@ -209,6 +249,8 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option { description, repository, api_version, + acadrust_source, + acadrust_declared, ribbon_order, command_prefixes, dir: PathBuf::new(), @@ -298,7 +340,36 @@ mod loader { manager.set_notification_handler(v4_support::notification_handler()); let mut out = Vec::new(); for d in &discovered { - if !d.api_compatible() || !d.lib_present { + if !d.api_compatible() { + continue; + } + if !d.lib_present { + continue; + } + if ocs_plugin_api::version_info::uses_acadrust_gate(d.api_version) + && d.acadrust_declared + && d.acadrust_source.is_none() + { + eprintln!( + "[plugin] {} declares acadrust metadata but has no fingerprint; cannot verify compatibility", + d.id + ); + } + if !d.acadrust_compatible() { + let host_src = ocs_plugin_api::version_info::host_acadrust_source(); + let plugin_hash = d + .acadrust_source + .as_deref() + .and_then(ocs_plugin_api::version_info::acadrust_source_hash) + .unwrap_or("unknown"); + let host_hash = ocs_plugin_api::version_info::acadrust_source_hash(host_src) + .unwrap_or("unknown"); + out.push(( + d.id.clone(), + Err(format!( + "Plugin built for acadrust @{plugin_hash}, but this host uses @{host_hash}" + )), + )); continue; } let Some(path) = lib_file(&d.dir) else { @@ -412,6 +483,121 @@ xdata_apps = ["MYPLUGIN_RECORD"] assert!(!p.loadable()); } + #[test] + fn undeclared_acadrust_falls_back_to_api_gate() { + let toml = r#" +[plugin] +id = "opencad.test" +name = "Test" +version = "0.1.0" +api_version = 4 +"#; + let p = parse_plugin_toml(toml).expect("parsed"); + assert!(!p.acadrust_declared); + assert!(p.acadrust_source.is_none()); + assert!(p.acadrust_compatible(), "undeclared acadrust is treated as compatible"); + } + + #[test] + fn declared_empty_acadrust_source_is_incompatible() { + let toml = r#" +[plugin] +id = "opencad.test" +name = "Test" +version = "0.1.0" +api_version = 4 + +[opencad] +acadrust_source = "" +"#; + let p = parse_plugin_toml(toml).expect("parsed"); + assert!(p.acadrust_declared); + assert!(p.acadrust_source.is_none()); + assert!(!p.acadrust_compatible(), "declared but empty source is incompatible"); + assert!(!p.loadable()); + } + + #[test] + fn acadrust_source_outside_opencad_is_ignored() { + let toml = r#" +[plugin] +id = "opencad.test" +api_version = 4 +acadrust_source = "0123456789012345678901234567890123456789" +"#; + let p = parse_plugin_toml(toml).expect("parsed"); + assert!(!p.acadrust_declared); + assert!(p.acadrust_source.is_none()); + } + + #[test] + fn acadrust_mismatch_detected() { + let host = ocs_plugin_api::version_info::host_acadrust_source(); + let other = if host.contains("94df2c3") { + "git+https://github.com/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb" + } else { + "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd" + }; + let toml = format!( + r#" +[plugin] +id = "opencad.test" +name = "Test" +version = "0.1.0" +api_version = 4 + +[opencad] +acadrust_source = "{other}" +"# + ); + let p = parse_plugin_toml(&toml).expect("parsed"); + assert!(p.acadrust_declared); + assert!(!p.acadrust_compatible(), "mismatched acadrust fingerprint should be incompatible"); + assert!(!p.loadable()); + } + + #[test] + fn acadrust_match_detected() { + let host = ocs_plugin_api::version_info::host_acadrust_source(); + let toml = format!( + r#" +[plugin] +id = "opencad.test" +name = "Test" +version = "0.1.0" +api_version = 4 + +[opencad] +acadrust_source = "{host}" +"# + ); + let p = parse_plugin_toml(&toml).expect("parsed"); + assert!(p.acadrust_declared); + assert!(p.acadrust_compatible(), "matching acadrust fingerprint should be compatible"); + } + + #[test] + fn acadrust_gate_only_applies_to_api_v4_and_newer() { + let toml = r#" +[plugin] +id = "opencad.test" +name = "Test" +version = "0.1.0" +api_version = 2 + +[opencad] +acadrust_source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb" +"#; + let mut p = parse_plugin_toml(toml).expect("parsed"); + p.lib_present = true; + assert!(p.acadrust_declared); + assert!( + p.acadrust_compatible(), + "API v2 plugin should bypass acadrust gate" + ); + assert!(p.loadable()); + } + /// Integration smoke test for the out-of-process plugin path. /// Set `OCS_TEST_PLUGIN` to the built cdylib path and make sure the /// `OpenCADStudio` binary is built; the test uses it as the runner host. diff --git a/src/plugin/marketplace.rs b/src/plugin/marketplace.rs index 797580c4..f3f6c5f3 100644 --- a/src/plugin/marketplace.rs +++ b/src/plugin/marketplace.rs @@ -219,9 +219,29 @@ pub fn fetch_release_info(repo: &str) -> Result, String> { let manifest_text = download_string(&manifest_asset.url)?; let manifest = external::parse_plugin_toml(&manifest_text) .ok_or_else(|| format!("release {} plugin.toml is missing an id", release.tag))?; + let acadrust_source = manifest.acadrust_source.clone(); + let acadrust_declared = manifest.acadrust_declared; + let acadrust_compatible = if !ocs_plugin_api::version_info::uses_acadrust_gate( + manifest.api_version, + ) { + true + } else if !acadrust_declared { + true + } else { + match acadrust_source.as_deref() { + None | Some("") => false, + Some(source) => ocs_plugin_api::version_info::acadrust_sources_compatible( + source, + ocs_plugin_api::version_info::host_acadrust_source(), + ), + } + }; Ok::<_, String>(ReleaseInfo { tag: release.tag, api_version: manifest.api_version, + acadrust_source, + acadrust_declared, + acadrust_compatible, }) })(); match result { @@ -229,6 +249,19 @@ pub fn fetch_release_info(repo: &str) -> Result, String> { Err(error) => last_error = Some(error), } } + + // Once a repo declares a fingerprint, require it on later API versions. + let any_declared = info.iter().any(|r| r.acadrust_declared); + if any_declared { + for r in &mut info { + if !r.acadrust_declared + && ocs_plugin_api::version_info::uses_acadrust_gate(r.api_version) + { + r.acadrust_compatible = false; + } + } + } + if info.is_empty() { Err(last_error.unwrap_or_else(|| "no installable releases found".to_string())) } else { @@ -283,8 +316,8 @@ fn download_bytes(url: &str) -> Result, String> { } /// Download and install a release's package into the plugins folder. Verifies -/// the API version from the package's `plugin.toml` first. Returns the plugin -/// id on success. +/// the API version and, when present, the `acadrust` fingerprint from the +/// package's `plugin.toml` first. Returns the plugin id on success. pub fn install(release: &Release, repository: &str) -> Result { let lib = release.lib_asset().ok_or("no library for this platform")?; let toml = release.toml_asset().ok_or("release has no plugin.toml")?; @@ -300,6 +333,34 @@ pub fn install(release: &Release, repository: &str) -> Result { )); } + if ocs_plugin_api::version_info::uses_acadrust_gate(manifest.api_version) + && manifest.acadrust_declared + { + let Some(source) = manifest.acadrust_source.as_deref() else { + return Err( + "Release declares acadrust metadata but has no source; cannot verify ABI compatibility".to_string(), + ); + }; + if source.is_empty() { + return Err( + "Release declares acadrust metadata but has no source; cannot verify ABI compatibility".to_string(), + ); + } + if !ocs_plugin_api::version_info::acadrust_sources_compatible( + source, + ocs_plugin_api::version_info::host_acadrust_source(), + ) { + let host_src = ocs_plugin_api::version_info::host_acadrust_source(); + let plugin_hash = ocs_plugin_api::version_info::acadrust_source_hash(source) + .unwrap_or("unknown"); + let host_hash = ocs_plugin_api::version_info::acadrust_source_hash(host_src) + .unwrap_or("unknown"); + return Err(format!( + "Plugin built for acadrust @{plugin_hash}, but this host uses @{host_hash}" + )); + } + } + let dir = external::plugins_dir() .ok_or("cannot locate the plugins folder")? .join(&manifest.id); diff --git a/src/plugin/v4_support.rs b/src/plugin/v4_support.rs index 23b7d205..59259f0f 100644 --- a/src/plugin/v4_support.rs +++ b/src/plugin/v4_support.rs @@ -42,6 +42,15 @@ pub fn on_tab_closed(tab_id: u64) { broadcast(HostNotification::DocumentTabClosed { tab_id }); } +/// Broadcast a selection change to V4 plugins. +/// +/// `HostNotification::SelectionChangedV4` carries the active tab id and the +/// current selection set. The caller is responsible for only calling this when +/// the selection actually changed; see `OpenCADStudio::notify_plugins_selection_changed`. +pub fn publish_selection_changed_v4(tab_id: u64, handles: Vec) { + broadcast(HostNotification::SelectionChangedV4 { tab_id, handles }); +} + /// V4 notification handler installed on the plugin manager. /// /// Forwards REPL status messages to the log; other notifications are handled diff --git a/src/scene/annotative.rs b/src/scene/annotative.rs index 0c887277..8e3af638 100644 --- a/src/scene/annotative.rs +++ b/src/scene/annotative.rs @@ -693,29 +693,9 @@ pub fn effective_annotation_scale_for( return fallback; } - // Unlike MTEXT / DIMENSION contexts, an MLEADER context carries its - // already-scaled text height and overall scale factor. Keep the text - // height as stored; make `ml.scale_factor * anno_scale` resolve to the - // active context's scale factor for arrows, doglegs, and fallback text. - if let EntityType::MultiLeader(mleader) = entity { - let Some(active) = - active_object_context_for_scale(doc, entity.common().handle, scale_handle) - else { - return fallback; - }; - let ObjectContextKind::MLeader(context) = &active.kind else { - return fallback; - }; - let base = mleader.scale_factor; - if base.abs() <= 1.0e-12 { - return fallback; - } - let relative = context.scale_factor / base; - return if relative.is_finite() && relative > 0.0 { - relative as f32 - } else { - fallback - }; + // MLEADER resolves the active-to-stored context ratio while tessellating. + if matches!(entity, EntityType::MultiLeader(_)) { + return fallback; } let Some(coll_h) = annotation_scales_dict(doc, entity.common().handle) else { @@ -1586,47 +1566,149 @@ pub fn apply_mleader_style( } } } +fn apply_mleader_style_at_display_scale( + entity: &mut acadrust::entities::MultiLeader, + style: &acadrust::objects::MultiLeaderStyle, + display_scale: f64, +) { + apply_mleader_style(entity, style); + + let scale = if display_scale.is_finite() + && display_scale > 1.0e-12 + { + display_scale + } else { + 1.0 + }; + + // Context sizes are stored at their display scale. + entity.context.scale_factor = scale; + + entity.context.text_height = + style.text_height * scale; + + entity.context.arrowhead_size = + style.arrowhead_size * scale; + + entity.context.landing_gap = + style.landing_gap * scale; + + for root in &mut entity.context.leader_roots { + root.landing_distance = + style.landing_distance * scale; + + root.text_attachment_direction = + entity.text_attachment_direction; + } +} pub fn apply_mleader_style_to_object( doc: &mut CadDocument, handle: Handle, style: &acadrust::objects::MultiLeaderStyle, ) -> bool { - let Some(EntityType::MultiLeader(original)) = doc.get_entity(handle).cloned() else { + let Some(EntityType::MultiLeader(original)) = + doc.get_entity(handle).cloned() + else { return false; }; + + let base_display_scale = if style.is_annotative { + let scale = original.context.scale_factor; + + if scale.is_finite() && scale > 1.0e-12 { + scale + } else { + 1.0 + } + } else { + let scale = style.scale_factor; + + if scale.is_finite() && scale > 1.0e-12 { + scale + } else { + 1.0 + } + }; + let mut styled = original.clone(); - apply_mleader_style(&mut styled, style); - if let Some(EntityType::MultiLeader(entity)) = doc.get_entity_mut(handle) { + + apply_mleader_style_at_display_scale( + &mut styled, + style, + base_display_scale, + ); + + if let Some(EntityType::MultiLeader(entity)) = + doc.get_entity_mut(handle) + { *entity = styled; } - let leaf_handles: Vec<_> = annotation_scales_dict(doc, handle) - .and_then(|collection| as_dict(doc, collection)) - .map(|collection| collection.entries.iter().map(|(_, leaf)| *leaf).collect()) - .unwrap_or_default(); + let leaf_handles: Vec<_> = + annotation_scales_dict(doc, handle) + .and_then(|collection| as_dict(doc, collection)) + .map(|collection| { + collection + .entries + .iter() + .map(|(_, leaf)| *leaf) + .collect() + }) + .unwrap_or_default(); + for leaf_handle in leaf_handles { - let Some(ObjectType::ObjectContextData(leaf)) = doc.objects.get_mut(&leaf_handle) else { - continue; - }; - let ObjectContextKind::MLeader(context) = &mut leaf.kind else { - continue; - }; - let context_scale = context.scale_factor; - let text_height_ratio = if original.text_height.abs() > 1.0e-12 { - context.text_height / original.text_height - } else { - 1.0 - }; + let context_before = + match doc.objects.get(&leaf_handle) { + Some( + ObjectType::ObjectContextData(leaf) + ) => { + let ObjectContextKind::MLeader(context) = + &leaf.kind + else { + continue; + }; + + context.clone() + } + + _ => continue, + }; + + let context_scale = + if context_before.scale_factor.is_finite() + && context_before.scale_factor > 1.0e-12 + { + context_before.scale_factor + } else { + base_display_scale + }; + let mut per_scale = original.clone(); - per_scale.context.clone_from(context); - apply_mleader_style(&mut per_scale, style); - per_scale.context.scale_factor = context_scale; - if style.text_height > 0.0 && text_height_ratio.is_finite() { - per_scale.context.text_height = style.text_height * text_height_ratio; + + per_scale.context = + context_before; + + apply_mleader_style_at_display_scale( + &mut per_scale, + style, + context_scale, + ); + + if let Some( + ObjectType::ObjectContextData(leaf) + ) = doc.objects.get_mut(&leaf_handle) + { + if let ObjectContextKind::MLeader(context) = + &mut leaf.kind + { + context.clone_from( + &per_scale.context, + ); + } } - context.clone_from(&per_scale.context); } + true } @@ -1773,4 +1855,3 @@ pub fn is_annotative(doc: &CadDocument, entity: &EntityType) -> bool { } } - diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index 95886daf..73f3dbca 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -23,7 +23,10 @@ use acadrust::{CadDocument, EntityType, Handle}; use cadkernel::space::{Plane as KernelPlane, Vec3 as KernelVec3}; use crate::scene::convert::tessellate; -use crate::scene::model::wire_model::{PointMarker, SnapHint, TangentGeom, WireModel}; +use crate::scene::model::wire_model::{ + decode_pattern_station_map, encode_pattern_stations, pattern_station_values, PatternStationMap, + PatternStationPiece, PointMarker, SnapHint, TangentGeom, WireModel, +}; const MAX_NESTING_DEPTH: usize = 32; /// Skip wires whose world-AABB projects to fewer than this many pixels in @@ -70,6 +73,7 @@ pub struct LocalWire { pub pattern_length: f32, pub pattern: [f32; 8], pub line_weight_px: f32, + pub pattern_stations: Vec, /// World-space band width for a wide polyline (see `WireModel.world_width`). /// Block-local; the expand-time transform scales it by the insert so the /// shader band grows with a scaled insert. `0.0` = a normal wire. @@ -865,6 +869,7 @@ fn tessellate_sub_local( pattern_length: pat_len, pattern: pat, line_weight_px: lw_px, + pattern_stations: wire.pattern_stations, world_width: wire.world_width, plinegen: wire.plinegen, plot_visible: frame_mode.is_none_or(|mode| mode == 1) @@ -1433,6 +1438,7 @@ struct StyleKey { pattern_length: u32, pattern: [u32; 8], line_weight_px: u32, + source_length: u32, /// Wide-polyline band width (bit-cast). Keeps bands of different widths — and /// bands vs thin wires of the same colour/style — in separate batches so the /// finalized WireModel carries one correct `world_width`. @@ -1475,6 +1481,10 @@ struct BatchEntry { pattern_length: f32, pattern: [f32; 8], line_weight_px: f32, + pattern_stations: Vec, + pattern_station_segments: Vec, + pattern_station_pieces: Vec, + source_length: f32, world_width: f32, point_marker: Option, aci: u8, @@ -1555,6 +1565,7 @@ impl BatchEntry { pat_len: f32, pat: [f32; 8], lw_px: f32, + source_length: f32, world_width: f32, point_marker: Option, aci: u8, @@ -1578,6 +1589,7 @@ impl BatchEntry { pattern_length: pat_len, pattern: pat, line_weight_px: lw_px, + source_length, world_width, point_marker, aci, @@ -1606,6 +1618,9 @@ impl Batches { if b.hide_unselected && !selected { b.points.clear(); b.points_low.clear(); + b.pattern_stations.clear(); + b.pattern_station_segments.clear(); + b.pattern_station_pieces.clear(); } let aabb = if b.min_x.is_infinite() { WireModel::UNBOUNDED_AABB @@ -1628,9 +1643,18 @@ impl Batches { ); } } + if !b.pattern_stations.is_empty() { + b.pattern_stations = encode_pattern_stations( + std::mem::take(&mut b.pattern_stations), + b.source_length, + &b.pattern_station_segments, + &b.pattern_station_pieces, + ); + } WireModel { point_marker: b.point_marker, taper_widths: Vec::new(), + pattern_stations: b.pattern_stations, world_width: b.world_width, depth_override: b.local_depth, display_visible: !b.hide_unselected || selected, @@ -1681,6 +1705,7 @@ fn style_key( pat_len: f32, pat: [f32; 8], lw_px: f32, + source_length: f32, world_width: f32, point_marker: Option, aci: u8, @@ -1714,6 +1739,7 @@ fn style_key( pat[7].to_bits(), ], line_weight_px: lw_px.to_bits(), + source_length: source_length.to_bits(), world_width: world_width.to_bits(), point_marker: point_marker.map(|marker| PointMarkerKey { origin: marker.origin.to_array().map(f64::to_bits), @@ -2060,6 +2086,116 @@ fn transformed_wire_length_scale(lw: &LocalWire, xform: &Transform) -> f32 { } } +struct TransformedStationData { + values: Vec, + source_length: f32, + point_segments: Vec, + pieces: Vec, +} + +fn transformed_station_data( + values: &[f32], + map: &PatternStationMap, + plinegen: bool, + xform: &Transform, +) -> Option { + if values.len() != map.point_segments.len() || map.pieces.is_empty() { + return None; + } + + let mut pieces = Vec::with_capacity(map.pieces.len()); + let mut global = 0.0_f32; + let mut local = 0.0_f32; + let mut current_segment = None; + for piece in &map.pieces { + if current_segment != Some(piece.source_segment) { + current_segment = Some(piece.source_segment); + local = 0.0; + } + let vector = Vector3::new( + piece.vector[0] as f64, + piece.vector[1] as f64, + piece.vector[2] as f64, + ); + let mapped = xform.matrix.transform_direction(vector); + let chord = vector.length(); + let mapped_chord = mapped.length(); + let original_length = (piece.source_distances[1] - piece.source_distances[0]).abs(); + let length = if chord > 1e-12 && mapped_chord.is_finite() { + original_length * (mapped_chord / chord) as f32 + } else { + original_length + }; + pieces.push(PatternStationPiece { + source_segment: piece.source_segment, + source_distances: [global, global + length], + segment_distances: [local, local + length], + vector: [mapped.x as f32, mapped.y as f32, mapped.z as f32], + }); + global += length; + local += length; + } + + let fallback_scale = map + .pieces + .last() + .map(|piece| piece.source_distances[1]) + .filter(|length| length.abs() > 1e-12) + .map_or(1.0, |length| global / length); + let mapped_values = values + .iter() + .zip(&map.point_segments) + .map(|(&value, &segment)| { + if segment < 0 { + return 0.0; + } + let segment = segment as u32; + let range = |piece: &PatternStationPiece| { + if plinegen { + piece.source_distances + } else { + piece.segment_distances + } + }; + let distance_to = |piece: &PatternStationPiece| { + let [start, end] = range(piece); + if value < start { + start - value + } else if value > end { + value - end + } else { + 0.0 + } + }; + let Some((old_piece, new_piece)) = map + .pieces + .iter() + .zip(&pieces) + .filter(|(piece, _)| piece.source_segment == segment) + .min_by(|(a, _), (b, _)| distance_to(a).total_cmp(&distance_to(b))) + else { + return value * fallback_scale; + }; + let old = range(old_piece); + let new = range(new_piece); + let span = old[1] - old[0]; + let t = if span.abs() > 1e-12 { + ((value - old[0]) / span).clamp(0.0, 1.0) + } else { + 0.0 + }; + new[0] + (new[1] - new[0]) * t + }) + .collect(); + + Some(TransformedStationData { + values: mapped_values, + source_length: global, + point_segments: map.point_segments.clone(), + pieces, + }) +} + fn transformed_point_marker(marker: PointMarker, xform: &Transform) -> (PointMarker, f64) { let origin = xform.apply(Vector3::new(marker.origin.x, marker.origin.y, marker.origin.z)); let map_direction = |direction: glam::DVec3| { @@ -2163,16 +2299,38 @@ fn emit_wire( lw.line_weight_px }; - // Pattern distances are stored in block-local units. Scale them by the - // wire's actual path-length ratio so uniform and non-uniform INSERTs both - // stay dimensionally consistent with their transformed geometry. - let pattern_scale = if final_pat_len > 0.0 { - transformed_wire_length_scale(lw, accum_xform) - } else { - 1.0 - }; + let station_values = pattern_station_values(&lw.pattern_stations, lw.points.len()); + let station_map = decode_pattern_station_map(&lw.pattern_stations, lw.points.len()); + let transformed_stations = station_values.and_then(|(values, _)| { + station_map.as_ref().and_then(|map| { + transformed_station_data(values, map, lw.plinegen, accum_xform) + }) + }); + let has_stations = station_values.is_some(); + let pattern_scale = transformed_stations.as_ref().map_or_else( + || { + if final_pat_len > 0.0 || has_stations { + transformed_wire_length_scale(lw, accum_xform) + } else { + 1.0 + } + }, + |data| { + station_values.map_or(1.0, |(_, source_length)| { + if source_length.abs() > 1e-12 { + data.source_length / source_length + } else { + 1.0 + } + }) + }, + ); let final_pat_len = final_pat_len * ctx.pslt_factor * pattern_scale; let final_pat = final_pat.map(|v| v * ctx.pslt_factor * pattern_scale); + let source_length = transformed_stations.as_ref().map_or_else( + || station_values.map_or(0.0, |(_, length)| length * pattern_scale), + |data| data.source_length, + ); // A wide polyline's band width is baked in block-local units; scale it by // the insert transform so the shader band matches the scaled geometry. @@ -2211,6 +2369,7 @@ fn emit_wire( final_pat_len, final_pat, final_lw_px, + source_length, final_world_width, point_marker, final_aci, @@ -2223,6 +2382,12 @@ fn emit_wire( local_depth, ); + if transformed_stations.is_some() { + if let Some(closed) = out.by_style.remove(&key) { + out.closed.push(closed); + } + } + // If the open batch for this style would exceed wgpu's per-buffer limit // after appending this wire, finalize it now and start a fresh batch. if let Some(existing) = out.by_style.get(&key) { @@ -2241,6 +2406,7 @@ fn emit_wire( final_pat_len, final_pat, final_lw_px, + source_length, final_world_width, point_marker, final_aci, @@ -2253,6 +2419,9 @@ fn emit_wire( ) }); entry.local_depth = local_depth; + if let Some(data) = &transformed_stations { + entry.pattern_station_pieces.clone_from(&data.pieces); + } // NaN separator between previously-appended geometry and this wire so the // GPU shader treats them as disconnected polylines within one buffer. @@ -2263,14 +2432,33 @@ fn emit_wire( if needs_sep { entry.points.push([f32::NAN; 3]); entry.points_low.push([0.0; 3]); + if has_stations { + entry.pattern_stations.push(0.0); + } + if transformed_stations.is_some() { + entry.pattern_station_segments.push(-1); + } } // Iterate paired with the matching low residual so the GPU keeps // sub-f32 precision once the INSERT transform lands the wire in // world space at UTM-scale coordinates. for (idx, p) in lw.points.iter().enumerate() { + let station = transformed_stations + .as_ref() + .map(|data| data.values[idx]) + .or_else(|| has_stations.then(|| lw.pattern_stations[idx] * pattern_scale)); + let station_segment = transformed_stations + .as_ref() + .map(|data| data.point_segments[idx]); if p[0].is_nan() { entry.points.push([f32::NAN; 3]); entry.points_low.push([0.0; 3]); + if let Some(station) = station { + entry.pattern_stations.push(station); + } + if let Some(segment) = station_segment { + entry.pattern_station_segments.push(segment); + } continue; } let pl = lw.points_low.get(idx).copied().unwrap_or([0.0; 3]); @@ -2311,6 +2499,12 @@ fn emit_wire( } entry.points.push(q); entry.points_low.push([qx_l, qy_l, qz_l]); + if let Some(station) = station { + entry.pattern_stations.push(station); + } + if let Some(segment) = station_segment { + entry.pattern_station_segments.push(segment); + } } } diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index b65880a6..974fc5a2 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -10,15 +10,15 @@ pub fn general_section(entity: &EntityType) -> PropSection { } else { common.linetype.clone() }; - // Alpha 0 is the ByLayer default (Transparency::BY_LAYER); show it by name - // and fall back to a rounded percentage only for an explicit value. - let transp_display = if common.transparency.alpha() == 0 { - "ByLayer".to_string() - } else { - format!( + // Alpha 0 is ByLayer, Alpha 1 is ByBlock; show them by name and fall back + // to a rounded percentage for explicit values. + let transp_display = match common.transparency.alpha() { + 0 => "ByLayer".to_string(), + 1 => "ByBlock".to_string(), + alpha => format!( "{}", - (common.transparency.alpha() as f64 / 255.0 * 100.0).round() as u32 - ) + (alpha as f64 / 255.0 * 100.0).round() as u32 + ), }; // Hyperlink is stored in XDATA under the "PE_URL" application. @@ -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", @@ -81,7 +76,10 @@ pub fn general_section(entity: &EntityType) -> PropSection { Property { label: t!("Transparency").into_owned(), field: "transparency", - value: PropValue::EditText(transp_display), + value: PropValue::EditChoice { + value: transp_display, + options: vec!["ByLayer".to_string(), "ByBlock".to_string()], + }, }, Property { label: t!("Hyperlink").into_owned(), @@ -91,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) { @@ -136,14 +129,26 @@ pub fn visualization_section(entity: &EntityType) -> Option { let material = match common.material_flags { 0 => "ByLayer", 1 => "ByBlock", + 2 => "Global", _ => "Custom", }; + let mut options = vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Global".to_string(), + ]; + if !options.iter().any(|o| o == material) && !material.is_empty() { + options.push(material.to_string()); + } Some(PropSection { title: t!("3D Visualization").into_owned(), props: vec![Property { label: t!("Material").into_owned(), field: "material", - value: PropValue::ReadOnly(material.into()), + value: PropValue::Choice { + selected: material.to_string(), + options, + }, }], }) } diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 75d15dcb..07c9dab4 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -315,7 +315,9 @@ impl Scene { self.camera_generation = saved_generation; if let Some((min, max)) = refreshed { - self.camera.borrow_mut().fit_depth_to_bounds(min, max); + self.camera + .borrow_mut() + .fit_depth_to_bounds_f64(min, max); self.projection_bounds_epoch.set(self.geometry_epoch); } } @@ -672,11 +674,7 @@ impl Scene { // The saved-view convention uses a 24 mm vertical aperture. This is // the inverse of the projection path's documented // `distance = view_height * lens / 24` relation. - let fov_y = if perspective { - 2.0 * (12.0 / lens_length).atan() - } else { - 45.0_f32.to_radians() - }; + let fov_y = 2.0 * (12.0 / lens_length).atan(); let distance = ((view_height as f32 / 2.0) / (fov_y * 0.5).tan()).max(0.001); Some(Camera { target, @@ -729,9 +727,9 @@ impl Scene { let view_dir = cam.rotation * glam::Vec3::Z; let view_height = cam.ortho_size() * 2.0; let target_wcs = acadrust::types::Vector3 { - x: (cam.target.x as f64) + [0.0_f64; 3][0], - y: (cam.target.y as f64) + [0.0_f64; 3][1], - z: (cam.target.z as f64) + [0.0_f64; 3][2], + x: cam.target.x, + y: cam.target.y, + z: cam.target.z, }; entry.lower_left = lower_left; entry.upper_right = upper_right; @@ -1186,9 +1184,11 @@ impl Scene { if !ok { return; } - let min = glam::Vec3::new(lo.x as f32, lo.y as f32, lo.z as f32); - let max = glam::Vec3::new(hi.x as f32, hi.y as f32, hi.z as f32); - self.camera.borrow_mut().fit_depth_to_bounds(min, max); + let min = glam::DVec3::new(lo.x, lo.y, lo.z); + let max = glam::DVec3::new(hi.x, hi.y, hi.z); + self.camera + .borrow_mut() + .fit_depth_to_bounds_f64(min, max); } fn fit_paper_space_extents(&mut self) { @@ -1335,15 +1335,8 @@ impl Scene { self.annotation_all_visible(), None, ); - // Ray / XLine tessellate as ±DISPLAY_EXTENT display segments - // (entities/ray.rs) — their endpoints are rendering artifacts, not - // drawing extent. A construction line through the drawing defeats - // both rejects below: its centroid sits at the base point (inside - // the consensus cluster), and in a fresh drawing `local_extent_max` - // is still the 1e9 default, so the far points pass the `lim` filter - // too (issue #284). Exclude them up front — an infinite construction - // line should never contribute to ZOOM Extents — and fall back to their - // base points if the drawing holds nothing else. + // Infinite display segments do not contribute to drawing extents. + // Preserve their base points as a fallback for otherwise empty drawings. let mut infinite_base_pts: Vec = Vec::new(); wires.retain(|w| { let is_infinite = Self::handle_from_wire_name(&w.name) @@ -1389,18 +1382,12 @@ impl Scene { return; } - // Per-wire centroid pass — used both for the absolute-magnitude reject - // (`local_extent_max`) and for the IQR-based outlier reject below. - // A wire whose centroid sits far outside the drawing's consensus - // cluster is an orphan (block-defn entity that leaked into MSPACE, - // bogus hatch boundary, Ray/XLine far point) and must not poison the - // bounding box. + // Collect wire centroids for robust outlier rejection. struct WireCent { idx: usize, cx: f32, cy: f32, } - let lim = self.local_extent_max; let mut cents: Vec = Vec::with_capacity(wires.len()); for (idx, wire) in wires.iter().enumerate() { let mut sx = 0.0_f64; @@ -1426,26 +1413,8 @@ impl Scene { return; } - // Robust drawing centre (median centroid). `lim` is a span RELATIVE to - // this centre, so every reject below is distance-from-centre — geometry - // now reaches fit_all as absolute coordinates (no world_offset), which - // at UTM scale are ~5.7e6; an absolute `|x| > lim` test would reject the - // entire drawing and make ZOOM Extents a no-op. - let (mcx, mcy) = { - let mut xs: Vec = cents.iter().map(|c| c.cx).collect(); - let mut ys: Vec = cents.iter().map(|c| c.cy).collect(); - if xs.is_empty() { - (0.0_f32, 0.0_f32) - } else { - xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - (xs[xs.len() / 2], ys[ys.len() / 2]) - } - }; - - // IQR-based reject only kicks in with enough samples for the quartiles - // to be meaningful. Below that, the centre-relative `lim` filter is the - // only gate (legacy behavior). + // Quartile rejection needs enough samples to be meaningful. With fewer + // wires, every finite visible point belongs to the drawing extents. let (rx_lo, rx_hi, ry_lo, ry_hi) = if cents.len() >= 8 { let mut xs: Vec = cents.iter().map(|c| c.cx).collect(); let mut ys: Vec = cents.iter().map(|c| c.cy).collect(); @@ -1456,17 +1425,18 @@ impl Scene { let q3x = q(&xs, 0.75); let q1y = q(&ys, 0.25); let q3y = q(&ys, 0.75); - // k=10× the inter-quartile span is permissive enough to keep - // legitimate sparse outlying geometry (annotation labels, scattered - // dim leaders) but tight enough to drop a single wire stranded at - // -world_offset. `max(1.0)` guards against a degenerate IQR=0 - // (e.g. all wires at the same centroid). + // Keep sparse annotations while rejecting isolated corrupt wires. const K: f32 = 10.0; let dx = (q3x - q1x).max(1.0) * K; let dy = (q3y - q1y).max(1.0) * K; (q1x - dx, q3x + dx, q1y - dy, q3y + dy) } else { - (mcx - lim, mcx + lim, mcy - lim, mcy + lim) + ( + f32::NEG_INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::INFINITY, + ) }; let mut min = glam::Vec3::splat(f32::MAX); @@ -1480,9 +1450,6 @@ impl Scene { if !x.is_finite() || !y.is_finite() || !z.is_finite() { continue; } - if (x - mcx).abs() > lim || (y - mcy).abs() > lim { - continue; - } min = min.min(glam::Vec3::new(x, y, z)); max = max.max(glam::Vec3::new(x, y, z)); } @@ -1557,7 +1524,7 @@ impl Scene { if index == active { tile.camera = live_camera.clone(); } else { - tile.camera.fit_to_bounds(min, max, aspect); + tile.camera.fit_to_bounds_f64(min, max, aspect); } } if let Some(aspect) = aspects.get(active) { diff --git a/src/scene/convert/acad_to_render.rs b/src/scene/convert/acad_to_render.rs index f7f1bdb9..478da682 100644 --- a/src/scene/convert/acad_to_render.rs +++ b/src/scene/convert/acad_to_render.rs @@ -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, /// 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, } +#[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 { @@ -61,6 +70,15 @@ pub enum RenderObject { Lines(Vec<[f64; 3]>), /// Like Lines but linetype pattern restarts at each NaN-separated segment (plinegen=false). SegmentedLines(Vec<[f64; 3]>), + /// Kernel band boundary with centreline-based linetype stations. + BoundaryLines { + points: Vec<[f64; 3]>, + stations: Vec, + point_segments: Vec, + station_pieces: Vec, + source_length: f32, + plinegen: bool, + }, /// A wide polyline whose band width VARIES (a taper): a continuous WCS point /// list paired index-for-index with a per-point full band width. The wire /// shader interpolates the two endpoint widths of each segment so the band diff --git a/src/scene/convert/acis_export.rs b/src/scene/convert/acis_export.rs index 0f522478..48e3730c 100644 --- a/src/scene/convert/acis_export.rs +++ b/src/scene/convert/acis_export.rs @@ -3,12 +3,21 @@ //! Analytic surfaces remain analytic instead of becoming facets. use cadkernel::acis::append; -use acadrust::entities::acis::SatDocument; +use acadrust::entities::acis::{SabReader, SabWriter, SatDocument}; use cadkernel::brep::Body; /// Returns `None` when the body contains an unsupported record form. -pub fn planar_solid_to_sat(body: &Body) -> Option { +pub fn solid_to_sat(body: &Body) -> Option { let mut document = SatDocument::new(); append(body, &mut document).ok()?; - Some(document) + let document = SatDocument::parse(&document.to_sat_string()).ok()?; + let valid = |candidate: &SatDocument| { + let (restored, loss) = cadkernel::acis::lift(candidate); + loss.is_empty() && restored.len() == 1 && restored[0].validate().is_empty() + }; + if !valid(&document) { + return None; + } + let binary = SabWriter::write(&document); + valid(&SabReader::read(&binary).ok()?).then_some(document) } diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 4729db5d..cf365b8e 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -1,5 +1,6 @@ use acadrust::entities::acis::{SabReader, SatBody, SatDocument}; use acadrust::entities::{Body, Region, Solid3D}; +use cadkernel::brep::{self, Body as KernelBody}; use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; @@ -43,14 +44,6 @@ pub(crate) fn body_transform( } else if let Some(value) = token.as_float() { values.push(value); } else if let Some(value) = token.as_integer() { - // A SAB-encoded transform's exact values (0/1 in an identity - // matrix - the common case for a freshly drawn solid) round-trip - // as compact SAB integer tokens (tag 0x04), not doubles. Missing - // this fallback silently parsed 0 of 13 values on any such - // transform, which read as "no resolvable transform" and aborted - // tessellation of the whole document (CMMS incident, 2026-08-27: - // reproduced on a solid saved and reopened, body_transform() - // parsed a valid identity transform record down to zero values). values.push(value as f64); } else if let Some(text) = token.as_string() { for word in text.split_ascii_whitespace() { @@ -218,6 +211,33 @@ fn parse_acis( }) } +pub fn kernel_body(solid: &Solid3D) -> Option { + let sat = parse_acis( + || solid.parse_sat(), + solid.acis_data.is_binary, + &solid.acis_data.sab_data, + )?; + let (mut bodies, loss) = cadkernel::acis::lift(&sat); + if !loss.is_empty() || bodies.len() != 1 { + return None; + } + let body = bodies.pop()?; + let source = body.provenance.source()?; + let Some((matrix, translation, scale)) = body_transform(&sat, source.index() as usize).ok()? + else { + return Some(body); + }; + brep::transform( + &body, + &brep::Placement { + x_axis: [scale * matrix[0], scale * matrix[1], scale * matrix[2]], + y_axis: [scale * matrix[3], scale * matrix[4], scale * matrix[5]], + z_axis: [scale * matrix[6], scale * matrix[7], scale * matrix[8]], + origin: translation, + }, + ) +} + fn remap_acis_material_bindings( set: &mut MeshLodSet, acis: &acadrust::entities::AcisData, diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index eab11c21..02d0dcd5 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -416,6 +416,7 @@ fn tessellate_entity_inner( | EntityType::MText(_) | EntityType::Dimension(_) | EntityType::MultiLeader(_) + | EntityType::Tolerance(_) ) { crate::scene::annotative::effective_annotation_scale_for( document, @@ -527,7 +528,7 @@ fn tessellate_entity_inner( if let EntityType::Viewport(vp) = e { // The sheet viewport (overall/id=1) is never shown — it represents the // paper boundary, not a user-defined content window. - if !Scene::is_content_viewport(vp) { + if Scene::is_sheet_viewport(document, vp) { return vec![]; } let is_active = active_viewport == Some(h); @@ -750,6 +751,7 @@ fn tessellate_entity_inner( return vec![WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -817,7 +819,10 @@ fn tessellate_entity_inner( graph.walk_insert( &insert, h, - |_, _| true, + |sub, context| { + // Direct POINTs are dimension definition markers. + context.insert_path.len() != 1 || !matches!(sub, EntityType::Point(_)) + }, |sub, context| { let has_book_color = view::render::has_resolved_book_color(document, sub); let color_byblock = @@ -1110,6 +1115,7 @@ fn tessellate_entity_inner( let marker = WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -1508,6 +1514,7 @@ fn lod_stub_wire( WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -1602,6 +1609,7 @@ fn lod_stub_wire_3d( WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index be7cfa20..108c2515 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -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; @@ -147,6 +222,7 @@ fn point_cloud_wires( let mut wires = vec![WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -400,6 +476,7 @@ pub fn tessellate( out.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -562,6 +639,7 @@ pub fn tessellate( elem_wires.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -630,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 ─────────────── @@ -730,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); } @@ -740,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); } @@ -790,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, + ); + } } } } @@ -846,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 { @@ -859,17 +1012,23 @@ 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, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -903,23 +1062,29 @@ 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, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -972,6 +1137,7 @@ pub fn tessellate( wires.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -1005,6 +1171,7 @@ pub fn tessellate( wires.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -1068,6 +1235,7 @@ pub fn tessellate( out.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -1113,6 +1281,7 @@ pub fn tessellate( out.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -1155,6 +1324,7 @@ pub fn tessellate( out.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -1190,6 +1360,7 @@ pub fn tessellate( out.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -1259,6 +1430,7 @@ pub fn tessellate( return vec![WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -1355,8 +1527,7 @@ pub fn tessellate( } else { color }; - // Basic curve entities use the Lines path for large-coordinate precision, - // but they must still preserve the entity's resolved linetype pattern. + // Basic curves keep their resolved linetype. let (edge_pattern_length, edge_pattern) = if matches!( entity, @@ -1385,7 +1556,8 @@ pub fn tessellate( out.push(WireModel { point_marker, taper_widths: Vec::new(), - world_width: polyline_band_width(entity), + pattern_stations: Vec::new(), + world_width: polyline_band_width(entity, document.header.fill_mode), depth_override: None, display_visible: true, plot_visible: true, @@ -1429,6 +1601,7 @@ pub fn tessellate( out.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, pick_tris: Vec::new(), pick_tris_low: Vec::new(), @@ -1464,6 +1637,7 @@ pub fn tessellate( out.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -1498,6 +1672,61 @@ pub fn tessellate( return out; } + RenderObject::BoundaryLines { + points, + stations, + point_segments, + station_pieces, + source_length, + plinegen, + } => { + let (local_pts, local_pts_low) = points_to_ds(points); + let key_vertices = te + .key_vertices + .into_iter() + .map(|[x, y, z]| [x, y, z]) + .collect(); + let station_data = crate::scene::model::wire_model::encode_pattern_stations( + stations, + source_length, + &point_segments, + &station_pieces, + ); + return vec![WireModel { + point_marker: None, + taper_widths: Vec::new(), + pattern_stations: station_data, + 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, + points: local_pts, + points_low: local_pts_low, + color, + selected, + pattern_length, + pattern, + line_weight_px, + snap_pts: te.snap_pts, + tangent_geoms: te.tangent_geoms, + aci: 0, + key_vertices, + plinegen, + aabb: WireModel::UNBOUNDED_AABB, + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + }]; + } + RenderObject::SegmentedLines(points) => { let (local_pts, local_pts_low) = points_to_ds(points); let snap_pts = te.snap_pts; @@ -1512,7 +1741,8 @@ pub fn tessellate( return vec![WireModel { point_marker: None, taper_widths: Vec::new(), - world_width: polyline_band_width(entity), + pattern_stations: Vec::new(), + world_width: polyline_band_width(entity, document.header.fill_mode), depth_override: None, display_visible: true, plot_visible: true, @@ -1561,6 +1791,7 @@ pub fn tessellate( return vec![WireModel { point_marker: None, taper_widths: widths, + pattern_stations: Vec::new(), world_width, depth_override: None, display_visible: true, @@ -1647,24 +1878,9 @@ pub fn tessellate( // f64 for the WireModel's double-single-era snap buffer. let snap_pts: Vec<(glam::DVec3, SnapHint)> = snap_pts.into_iter().map(|(p, h)| (p.as_dvec3(), h)).collect(); - // A paper-space viewport is a window, not a wireframe: give it an interior - // pick surface so a click anywhere inside the frame selects it. Ranked - // below edge and fill hits, so content drawn inside still wins the click. - // The sheet ("overall") viewport is the layout's own invisible camera - // frame covering the whole page — never pickable, or it would swallow - // every click over the real viewports beneath it. It is identified by the - // Layout object's viewport link (authoritative — DWG files carry id = 0 - // and this file class centres the sheet viewport off-origin, so neither - // the id nor the geometry heuristic alone is reliable), with - // `is_content_viewport` as the fallback classifier. - let is_sheet_vp = |vp: &acadrust::entities::Viewport| { - let h = vp.common.handle; - document.objects.values().any(|obj| { - matches!(obj, acadrust::objects::ObjectType::Layout(l) if l.viewport == h) - }) || !crate::scene::Scene::is_content_viewport(vp) - }; + // Paper viewports are pickable inside their frames; the sheet viewport is not. let (pick_tris, pick_tris_low) = match entity { - EntityType::Viewport(vp) if !is_sheet_vp(vp) => { + EntityType::Viewport(vp) if !crate::scene::Scene::is_sheet_viewport(document, vp) => { if let Some(polygon) = clipped_viewport_polygon.as_ref() { points_to_ds(crate::entities::mesh::triangulate_planar(polygon)) } else { @@ -1683,6 +1899,7 @@ pub fn tessellate( vec![WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -2140,14 +2357,11 @@ pub(crate) fn entity_z(entity: &EntityType) -> f32 { } } -/// Band width (drawing units) of a wide polyline whose flat band is drawn by -/// expanding its centre-line wire in the shader. `0.0` = a zero-width polyline, -/// a non-polyline (a normal screen-pixel wire), or a thickness-extruded one — an -/// LwPolyline thickens into a 3-D tube (`thick_wide_band`) and a Polyline2D -/// keeps its hatch band (only its centre-line extrudes; no tube yet), so neither -/// takes the flat shader band. A tapering polyline (per-vertex start/end widths) -/// collapses to its widest edge — the shader carries one width per wire. -fn polyline_band_width(entity: &EntityType) -> f32 { +/// Shader band width, or zero while FILLMODE draws the kernel boundary. +fn polyline_band_width(entity: &EntityType, fill_mode: bool) -> f32 { + if !fill_mode { + return 0.0; + } let w = match entity { EntityType::LwPolyline(p) if p.thickness.abs() <= 1e-10 => { let mut w = p.constant_width; diff --git a/src/scene/creation_style.rs b/src/scene/creation_style.rs index ed4a1fc5..442d6118 100644 --- a/src/scene/creation_style.rs +++ b/src/scene/creation_style.rs @@ -18,6 +18,7 @@ pub struct DimensionCreationDefaults { pub text_height: f64, pub gap: f64, pub arrow_size: f64, + pub jog_angle: f64, pub scale: f64, pub annotative: bool, } @@ -92,6 +93,9 @@ pub fn current_dimension_defaults(doc: &CadDocument) -> DimensionCreationDefault text_height: style.as_ref().map_or(2.5, |style| style.dimtxt), gap: style.as_ref().map_or(0.625, |style| style.dimgap), arrow_size: style.as_ref().map_or(2.5, |style| style.dimasz), + jog_angle: style + .as_ref() + .map_or(std::f64::consts::FRAC_PI_4, |style| style.dimjogang), scale: style.as_ref().map_or(1.0, |style| { if style.annotative { 1.0 @@ -134,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 { diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs new file mode 100644 index 00000000..9e752c68 --- /dev/null +++ b/src/scene/dimension_assoc.rs @@ -0,0 +1,1145 @@ +use acadrust::entities::Dimension; +use acadrust::objects::{ + AssocDimensionAssociation, AssocDimensionReference, AssociativeData, + AssociativeObject, ObjectType, +}; +use acadrust::types::{Handle, Vector3}; +use acadrust::EntityType; +use cadkernel::geom2d::{ + angle_within_arc, arc as tessellate_arc, arc_span, closest_point, Arc as KernelArc, + BulgeArc, Circle as KernelCircle, Curve as KernelCurve, DEFAULT_SEGMENTS_PER_RADIAN, +}; +use cadkernel::space::Plane; +use std::f64::consts::TAU; + +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 { + (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, + pub center: [f64; 2], + pub radius: f64, + pub start_angle: f64, + pub end_angle: f64, + pub limited: bool, + pub marker: i32, +} + +impl RadialSourceGeometry { + pub(crate) fn center_world(self) -> Vector3 { + vector3(self.plane.point_at(self.center)) + } + + pub(crate) fn point_at_angle(self, angle: f64) -> Vector3 { + vector3(self.plane.point_at([ + self.center[0] + self.radius * angle.cos(), + self.center[1] + self.radius * angle.sin(), + ])) + } + + pub(crate) fn angle_at(self, point: DPoint) -> f64 { + let projected = self.plane.project(point).unwrap_or(self.center); + (projected[1] - self.center[1]).atan2(projected[0] - self.center[0]) + } + + pub(crate) fn chord_at(self, point: DPoint) -> Vector3 { + self.point_at_angle(self.angle_at(point)) + } + + pub(crate) fn opposite_chord_at(self, point: DPoint) -> Vector3 { + self.point_at_angle(self.angle_at(point) + std::f64::consts::PI) + } + + fn contains_angle(self, angle: f64) -> bool { + !self.limited || angle_within_arc(angle, self.start_angle, self.end_angle) + } + + fn curve(self) -> KernelCurve { + if self.limited { + KernelCurve::Arc(KernelArc { + centre: self.center, + radius: self.radius, + start_angle: self.start_angle, + end_angle: self.end_angle, + }) + } else { + KernelCurve::Circle(KernelCircle { + centre: self.center, + radius: self.radius, + }) + } + } + + fn distance_squared_to(self, point: DPoint) -> f64 { + let Some(projected) = self.plane.project(point) else { + return f64::INFINITY; + }; + let planar = closest_point(&self.curve(), projected).distance; + let world = self.plane.point_at(projected); + let dx = world[0] - point[0]; + let dy = world[1] - point[1]; + let dz = world[2] - point[2]; + planar * planar + dx * dx + dy * dy + dz * dz + } +} + +type DPoint = [f64; 3]; + +fn vector3(point: DPoint) -> Vector3 { + Vector3::new(point[0], point[1], point[2]) +} + +fn dpoint(point: Vector3) -> DPoint { + [point.x, point.y, point.z] +} + +fn radial_candidates(entity: &EntityType) -> Vec { + let Some(planar) = crate::entities::curve::entity_curve(entity) else { + return Vec::new(); + }; + match planar.curve { + KernelCurve::Circle(circle) => vec![RadialSourceGeometry { + plane: planar.plane, + center: circle.centre, + radius: circle.radius, + start_angle: 0.0, + end_angle: 0.0, + limited: false, + marker: 0, + }], + KernelCurve::Arc(arc) => vec![RadialSourceGeometry { + plane: planar.plane, + center: arc.centre, + radius: arc.radius, + start_angle: arc.start_angle, + end_angle: arc.end_angle, + limited: true, + marker: 0, + }], + KernelCurve::Polyline(polyline) => (0..polyline.vertices.len()) + .filter_map(|index| { + let arc = polyline.segment_arc(index)?; + let (start_angle, end_angle) = if arc.sweep >= 0.0 { + (arc.start_angle, arc.end_angle) + } else { + (arc.end_angle, arc.start_angle) + }; + Some(RadialSourceGeometry { + plane: planar.plane, + center: arc.center, + radius: arc.radius, + start_angle, + end_angle, + limited: true, + marker: index as i32, + }) + }) + .collect(), + _ => Vec::new(), + } +} + +pub(crate) fn radial_source_at( + entity: &EntityType, + point: Vector3, +) -> Option { + radial_candidates(entity).into_iter().min_by(|first, second| { + first + .distance_squared_to(dpoint(point)) + .total_cmp(&second.distance_squared_to(dpoint(point))) + }) +} + +fn radial_source_for_marker(entity: &EntityType, marker: i32) -> Option { + radial_candidates(entity) + .into_iter() + .find(|candidate| candidate.marker == marker) +} + +fn radial_source_matching( + entity: &EntityType, + center: Vector3, + radius: f64, + chord: Vector3, +) -> Option { + radial_candidates(entity).into_iter().min_by(|first, second| { + let score = |candidate: &RadialSourceGeometry| { + point_distance_squared(candidate.center_world(), center) + + (candidate.radius - radius).powi(2) + + candidate.distance_squared_to(dpoint(chord)) * 1e-6 + }; + score(first).total_cmp(&score(second)) + }) +} + +fn point_distance_squared(first: Vector3, second: Vector3) -> f64 { + let dx = first.x - second.x; + let dy = first.y - second.y; + let dz = first.z - second.z; + dx * dx + dy * dy + dz * dz +} + +fn ocs_point(x: f64, y: f64, elevation: f64, normal: Vector3) -> Vector3 { + let point = crate::scene::view::transform::ocs_point_to_wcs( + (x, y, elevation), + (normal.x, normal.y, normal.z), + ); + Vector3::new(point.0, point.1, point.2) +} + +fn circle_curve(circle: &acadrust::entities::Circle) -> KernelCurve { + KernelCurve::Circle(KernelCircle { + centre: [circle.center.x, circle.center.y], + radius: circle.radius, + }) +} + +fn bulge_center_world( + first: [f64; 2], + second: [f64; 2], + bulge: f64, + elevation: f64, + normal: Vector3, +) -> Option { + let center = BulgeArc::from_bulge(first, second, bulge)?.center; + Some(ocs_point(center[0], center[1], elevation, normal)) +} + +fn next_segment_index(count: usize, closed: bool, segment: usize) -> Option { + if segment + 1 < count { + Some(segment + 1) + } else if closed && segment < count { + Some(0) + } else { + None + } +} + +fn polyline_arc_center(entity: &EntityType, segment: usize) -> Option { + match entity { + EntityType::LwPolyline(polyline) => { + let count = polyline.vertices.len(); + let first = *polyline.vertices.get(segment)?; + let second = *polyline + .vertices + .get(next_segment_index(count, polyline.is_closed, segment)?)?; + bulge_center_world( + [first.location.x, first.location.y], + [second.location.x, second.location.y], + first.bulge, + polyline.elevation, + polyline.normal, + ) + } + EntityType::Polyline2D(polyline) => { + let count = polyline.vertices.len(); + let first = polyline.vertices.get(segment)?; + let second = polyline + .vertices + .get(next_segment_index(count, polyline.is_closed(), segment)?)?; + bulge_center_world( + [first.location.x, first.location.y], + [second.location.x, second.location.y], + first.bulge, + polyline.elevation, + polyline.normal, + ) + } + _ => None, + } +} + +fn source_points(entity: &EntityType) -> Vec { + match entity { + EntityType::Line(line) => vec![line.start, line.end], + EntityType::Arc(arc) => vec![arc.start_point_wcs(), arc.end_point_wcs()], + EntityType::Circle(_) => Vec::new(), + EntityType::LwPolyline(polyline) => polyline + .vertices + .iter() + .map(|vertex| { + ocs_point( + vertex.location.x, + vertex.location.y, + polyline.elevation, + polyline.normal, + ) + }) + .collect(), + EntityType::Polyline2D(polyline) => polyline + .vertices + .iter() + .map(|vertex| { + ocs_point( + vertex.location.x, + vertex.location.y, + polyline.elevation, + polyline.normal, + ) + }) + .collect(), + _ => Vec::new(), + } +} + +fn source_reference(entity: &EntityType, point: Vector3) -> Option<(i32, f64)> { + let curve_parameter = match entity { + EntityType::Circle(circle) => { + let center = circle.center_wcs(); + if point_distance_squared(center, point) <= 1e-16 { + return Some((-3, 0.0)); + } + let (axis_x, axis_y) = circle.axes_wcs(); + let offset = point - center; + Some(offset.dot(&axis_y).atan2(offset.dot(&axis_x))) + } + EntityType::Arc(arc) => { + let local = crate::scene::view::transform::wcs_point_to_ocs( + (point.x, point.y, point.z), + (arc.normal.x, arc.normal.y, arc.normal.z), + ); + let dx = local.0 - arc.center.x; + let dy = local.1 - arc.center.y; + if dx * dx + dy * dy <= 1e-16 { + return Some((-3, 0.0)); + } + Some(dy.atan2(dx)) + } + _ => None, + }; + if let Some(parameter) = curve_parameter { + return Some((-2, parameter)); + } + source_marker(entity, point).map(|marker| (marker, 0.0)) +} + +fn source_marker(entity: &EntityType, point: Vector3) -> Option { + source_points(entity) + .into_iter() + .enumerate() + .min_by(|(_, first), (_, second)| { + point_distance_squared(*first, point) + .total_cmp(&point_distance_squared(*second, point)) + }) + .map(|(index, _)| index as i32) +} + +fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Option { + let source = *reference.xrefs.first()?; + let entity = scene.document.get_entity(source)?; + 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); + } + if reference.main_gs_marker == -3 { + return match entity { + EntityType::Circle(circle) => Some(circle.center_wcs()), + EntityType::Arc(arc) => Some(arc.center_wcs()), + _ => None, + }; + } + if reference.main_gs_marker == -2 { + return match entity { + EntityType::Circle(circle) => { + Some(circle.point_at_angle_wcs(reference.osnap_distance)) + } + EntityType::Arc(arc) => Some(arc.point_at_angle_wcs(reference.osnap_distance)), + _ => None, + }; + } + if let EntityType::Circle(circle) = entity { + let stored = crate::scene::view::transform::wcs_point_to_ocs( + ( + reference.osnap_point.x, + reference.osnap_point.y, + reference.osnap_point.z, + ), + (circle.normal.x, circle.normal.y, circle.normal.z), + ); + let curve = circle_curve(circle); + let stored_parameter = curve.parameter_at([stored.0, stored.1]) * TAU; + let parameter = if reference.osnap_distance.abs() > 1e-12 + || stored_parameter.abs() <= 1e-12 + { + reference.osnap_distance + } else { + stored_parameter + }; + let parameter = if parameter.is_finite() { parameter } else { 0.0 }; + let point = curve.point_at(parameter / TAU); + return Some(ocs_point( + point[0], + point[1], + circle.center.z, + circle.normal, + )); + } + source_points(entity) + .get(reference.main_gs_marker.max(0) as usize) + .copied() +} + +fn dimension_inference_points(dimension: &Dimension) -> Option<[Option; 2]> { + match dimension { + Dimension::Linear(linear) => Some([Some(linear.first_point), Some(linear.second_point)]), + Dimension::Aligned(aligned) => Some([Some(aligned.first_point), Some(aligned.second_point)]), + Dimension::Ordinate(ordinate) => Some([Some(ordinate.feature_location), None]), + _ => None, + } +} + +fn source_distance_squared(entity: &EntityType, point: Vector3) -> Option { + if let EntityType::Circle(circle) = entity { + let point = crate::scene::view::transform::wcs_point_to_ocs( + (point.x, point.y, point.z), + (circle.normal.x, circle.normal.y, circle.normal.z), + ); + let radial_error = closest_point(&circle_curve(circle), [point.0, point.1]).distance; + let plane_error = point.2 - circle.center.z; + return Some(radial_error * radial_error + plane_error * plane_error); + } + source_points(entity) + .into_iter() + .map(|candidate| point_distance_squared(candidate, point)) + .min_by(f64::total_cmp) +} + +fn dimension_reference_points(dimension: &Dimension) -> Vec { + match dimension { + Dimension::Linear(linear) => vec![linear.first_point, linear.second_point], + Dimension::Aligned(aligned) => vec![aligned.first_point, aligned.second_point], + Dimension::Angular3Pt(angular) => vec![ + angular.angle_vertex, + angular.first_point, + angular.second_point, + ], + Dimension::Angular2Ln(angular) => vec![ + angular.first_point, + angular.second_point, + angular.angle_vertex, + 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, +) -> bool { + document.objects.values().any(|object| { + let ObjectType::Associative(object) = object else { + return false; + }; + let AssociativeData::DimensionAssociation(association) = &object.data else { + return false; + }; + association.dimension == dimension + && association.associativity != 0 + && association.references.iter().flatten().any(|reference| { + reference + .xrefs + .iter() + .any(|source| document.get_entity(*source).is_some()) + }) + }) +} + +pub(crate) fn radial_extension_points( + document: &acadrust::CadDocument, + dimension: Handle, + gap: f64, + extension: f64, +) -> Option> { + let EntityType::Dimension(dimension_entity) = document.get_entity(dimension)? else { + return None; + }; + let target_point = match dimension_entity { + Dimension::Radius(radius) => radius.definition_point, + Dimension::Diameter(diameter) => diameter.angle_vertex, + Dimension::LargeRadial(radial) => radial.chord_point, + _ => return None, + }; + let association = document.objects.values().find_map(|object| { + let ObjectType::Associative(object) = object else { + return None; + }; + let AssociativeData::DimensionAssociation(association) = &object.data else { + return None; + }; + (association.dimension == dimension).then_some(association) + })?; + let reference = association.references[0].first()?; + let source = document.get_entity(*reference.xrefs.first()?)?; + let radial = radial_source_for_marker(source, reference.main_gs_marker)?; + if !radial.limited || radial.radius <= 1e-12 { + return None; + } + let target = radial.angle_at(dpoint(target_point)); + if radial.contains_angle(target) { + return None; + } + + let from_start = arc_span(target, radial.start_angle); + let from_end = arc_span(radial.end_angle, target); + let gap_angle = gap.max(0.0) / radial.radius; + let extension_angle = extension.max(0.0) / radial.radius; + let span = from_start.min(from_end); + if span <= gap_angle + 1e-10 { + return None; + } + let (start, end, reverse) = if from_start <= from_end { + ( + target - extension_angle, + radial.start_angle - gap_angle, + true, + ) + } else { + ( + radial.end_angle + gap_angle, + target + extension_angle, + false, + ) + }; + let mut points: Vec<_> = tessellate_arc( + radial.center, + radial.radius, + start, + end, + 0.0, + DEFAULT_SEGMENTS_PER_RADIAN, + ) + .into_iter() + .map(|point| vector3(radial.plane.point_at([point[0], point[1]]))) + .collect(); + if reverse { + points.reverse(); + } + Some(points) +} + +impl Scene { + pub(crate) fn attach_dimension_association( + &mut self, + dimension: Handle, + sources: Vec>, + ) { + self.attach_dimension_association_sources( + dimension, + sources + .into_iter() + .map(|source| source.map(DimensionAssociationSource::inferred)) + .collect(), + ); + } + + pub(crate) fn attach_dimension_association_sources( + &mut self, + dimension: Handle, + sources: Vec>, + ) { + let Some(EntityType::Dimension(dimension_entity)) = + self.document.get_entity(dimension) + else { + return; + }; + let radial_data = match dimension_entity { + Dimension::Radius(radius) => Some(( + radius.angle_vertex, + radius.measurement(), + radius.definition_point, + )), + Dimension::Diameter(diameter) => Some(( + diameter.center(), + diameter.measurement() * 0.5, + diameter.angle_vertex, + )), + Dimension::LargeRadial(radial) => Some(( + radial.definition_point, + radial.measurement(), + radial.chord_point, + )), + _ => None, + }; + if let Some((center, measured_radius, chord)) = radial_data { + let Some(source) = sources.iter().flatten().next().copied() else { + return; + }; + let Some(source_entity) = self.document.get_entity(source.handle) else { + return; + }; + let radial = source + .marker + .and_then(|marker| radial_source_for_marker(source_entity, marker)) + .or_else(|| radial_source_matching(source_entity, center, measured_radius, chord)); + let Some(radial) = radial else { + return; + }; + let angle = if source.marker.is_some() && source.parameter.is_finite() { + source.parameter + } else { + radial.angle_at(dpoint(chord)) + }; + let reference = AssocDimensionReference { + class_name: "AcDbOsnapPointRef".to_string(), + osnap_type: 10, + xrefs: vec![source.handle], + main_subent_type: 1, + main_gs_marker: radial.marker, + osnap_distance: angle, + osnap_point: chord, + ..AssocDimensionReference::default() + }; + self.store_dimension_association( + dimension, + [vec![reference], Vec::new(), Vec::new(), Vec::new()], + 1, + sources, + ); + return; + } + let source_data = dimension_reference_points(dimension_entity); + if source_data.is_empty() { + return; + } + let resolved: Vec> = source_data + .iter() + .take(4) + .enumerate() + .map(|(index, point)| { + let source = sources.get(index).copied().flatten()?; + let entity = self.document.get_entity(source.handle)?; + let (marker, parameter) = match source.marker { + Some(marker) => (marker, source.parameter), + None => source_reference(entity, *point)?, + }; + let osnap_type = if matches!(entity, EntityType::Circle(_)) { + 10 + } else { + 1 + }; + Some((source.handle, marker, parameter, osnap_type)) + }) + .collect(); + if resolved.iter().all(Option::is_none) { + return; + } + + let reference = |source: Handle, + marker: i32, + parameter: f64, + osnap_type: u8, + point: Vector3| AssocDimensionReference { + class_name: "AcDbOsnapPointRef".to_string(), + osnap_type, + xrefs: vec![source], + main_subent_type: 1, + main_gs_marker: marker, + osnap_distance: parameter, + osnap_point: point, + ..AssocDimensionReference::default() + }; + let mut references: [Vec; 4] = + std::array::from_fn(|_| Vec::new()); + let mut associativity = 0; + for (index, resolved) in resolved.into_iter().enumerate() { + if index >= references.len() { + break; + } + if let Some((source, marker, parameter, osnap_type)) = resolved { + associativity |= 1 << index; + references[index].push(reference( + source, + marker, + parameter, + osnap_type, + source_data[index], + )); + } + } + + self.store_dimension_association(dimension, references, associativity, sources); + } + + fn store_dimension_association( + &mut self, + dimension: Handle, + references: [Vec; 4], + associativity: i32, + sources: Vec>, + ) { + let association_handle = self.document.allocate_handle(); + let mut object = AssociativeObject::new("DIMASSOC", "AcDbDimAssoc"); + object.handle = association_handle; + object.reactors.push(dimension); + object.data = AssociativeData::DimensionAssociation(AssocDimensionAssociation { + associativity, + dimension, + references, + ..AssocDimensionAssociation::default() + }); + self.document + .objects + .insert(association_handle, ObjectType::Associative(object)); + + let mut reactor_targets = vec![dimension]; + reactor_targets.extend(sources.into_iter().flatten().map(|source| source.handle)); + reactor_targets.sort_by_key(|handle| handle.value()); + reactor_targets.dedup(); + for handle in reactor_targets { + if let Some(entity) = self.document.get_entity_mut(handle) { + if !entity.common().reactors.contains(&association_handle) { + entity.common_mut().reactors.push(association_handle); + } + } + } + } + + pub(crate) fn infer_dimension_sources( + &self, + dimension: Handle, + ) -> [Option; 2] { + let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) + else { + return [None, None]; + }; + let radial_data = match entity { + Dimension::Radius(radius) => Some(( + radius.angle_vertex, + radius.measurement(), + radius.definition_point, + )), + Dimension::Diameter(diameter) => Some(( + diameter.center(), + diameter.measurement() * 0.5, + diameter.angle_vertex, + )), + Dimension::LargeRadial(radial) => Some(( + radial.definition_point, + radial.measurement(), + radial.chord_point, + )), + _ => None, + }; + if let Some((center, radius, chord)) = radial_data { + let tolerance = radius.abs().max(1.0) * 1e-9; + let source = self + .document + .entities() + .filter(|candidate| candidate.common().handle != dimension) + .filter_map(|candidate| { + let radial = radial_source_matching( + candidate, + center, + radius, + chord, + )?; + let center_error = point_distance_squared(radial.center_world(), center); + let radius_error = (radial.radius - radius).powi(2); + (center_error + radius_error <= tolerance * tolerance).then_some(( + center_error + radius_error, + candidate.common().handle, + )) + }) + .min_by(|first, second| first.0.total_cmp(&second.0)) + .map(|(_, handle)| handle); + return [source, None]; + } + let Some(points) = dimension_inference_points(entity) else { + return [None, None]; + }; + points.map(|point| { + point.and_then(|point| self.document + .entities() + .filter(|entity| entity.common().handle != dimension) + .filter_map(|entity| { + source_distance_squared(entity, point) + .map(|distance| (distance, entity.common().handle)) + }) + .filter(|(distance, _)| *distance <= 1e-16) + .min_by(|first, second| first.0.total_cmp(&second.0)) + .map(|(_, handle)| handle)) + }) + } + + pub(crate) fn refresh_associative_dimensions( + &mut self, + changes: &[(Handle, ChangeKind)], + ) -> Vec<(Handle, ChangeKind)> { + let changed: rustc_hash::FxHashSet<_> = + changes.iter().map(|(handle, _)| *handle).collect(); + if changed.is_empty() { + return Vec::new(); + } + let associations: Vec<_> = self + .document + .objects + .values() + .filter_map(|object| { + let ObjectType::Associative(object) = object else { + return None; + }; + let AssociativeData::DimensionAssociation(association) = &object.data else { + return None; + }; + association + .references + .iter() + .flatten() + .any(|reference| reference.xrefs.iter().any(|handle| changed.contains(handle))) + .then_some(association.clone()) + }) + .collect(); + + let mut refreshed = Vec::new(); + for association in associations { + let resolved: [Option; 4] = std::array::from_fn(|index| { + association.references[index] + .first() + .and_then(|reference| resolve_reference(self, reference)) + }); + let radial_source = association.references[0].first().and_then(|reference| { + let source = *reference.xrefs.first()?; + let entity = self.document.get_entity(source)?; + 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; + } + let Some(EntityType::Dimension(dimension)) = + self.document.get_entity_mut(association.dimension) + else { + continue; + }; + match dimension { + Dimension::Linear(linear) => { + if let Some(point) = resolved[0] { + linear.first_point = point; + } + if let Some(point) = resolved[1] { + linear.second_point = point; + } + linear.base.definition_point = linear.definition_point; + } + Dimension::Aligned(aligned) => { + if let Some(point) = resolved[0] { + aligned.first_point = point; + } + if let Some(point) = resolved[1] { + aligned.second_point = point; + } + aligned.base.definition_point = aligned.definition_point; + } + Dimension::Angular3Pt(angular) => { + if let Some(point) = resolved[0] { + angular.angle_vertex = point; + } + if let Some(point) = resolved[1] { + angular.first_point = point; + } + if let Some(point) = resolved[2] { + angular.second_point = point; + } + angular.base.definition_point = angular.definition_point; + } + Dimension::Angular2Ln(angular) => { + if let Some(point) = resolved[0] { + angular.first_point = point; + } + if let Some(point) = resolved[1] { + angular.second_point = point; + } + if let Some(point) = resolved[2] { + angular.angle_vertex = point; + } + if let Some(point) = resolved[3] { + angular.definition_point = point; + } + angular.base.definition_point = angular.definition_point; + } + Dimension::Radius(radius) => { + let Some((radial, angle)) = radial_source else { + continue; + }; + let old_chord = radius.definition_point; + let new_center = radial.center_world(); + let new_chord = radial.point_at_angle(angle); + let delta = Vector3::new( + new_chord.x - old_chord.x, + new_chord.y - old_chord.y, + new_chord.z - old_chord.z, + ); + radius.angle_vertex = new_center; + radius.definition_point = new_chord; + radius.base.definition_point = new_chord; + if radius.base.text_user_positioned { + radius.base.text_middle_point = radius.base.text_middle_point + delta; + radius.base.insertion_point = radius.base.insertion_point + delta; + } + radius.base.actual_measurement = radius.measurement(); + } + Dimension::Diameter(diameter) => { + let Some((radial, angle)) = radial_source else { + continue; + }; + let old_chord = diameter.angle_vertex; + let new_chord = radial.point_at_angle(angle); + let new_far_chord = radial.point_at_angle(angle + std::f64::consts::PI); + let delta = Vector3::new( + new_chord.x - old_chord.x, + new_chord.y - old_chord.y, + new_chord.z - old_chord.z, + ); + diameter.angle_vertex = new_chord; + diameter.definition_point = new_far_chord; + diameter.base.definition_point = new_far_chord; + if diameter.base.text_user_positioned { + diameter.base.text_middle_point = diameter.base.text_middle_point + delta; + diameter.base.insertion_point = diameter.base.insertion_point + delta; + } + diameter.base.actual_measurement = diameter.measurement(); + } + Dimension::LargeRadial(radial) => { + let Some((source, angle)) = radial_source else { + continue; + }; + let old_center = radial.definition_point; + let new_center = source.center_world(); + let new_chord = source.point_at_angle(angle); + let center_delta = Vector3::new( + new_center.x - old_center.x, + new_center.y - old_center.y, + new_center.z - old_center.z, + ); + radial.definition_point = new_center; + radial.base.definition_point = new_center; + radial.chord_point = new_chord; + if point_distance_squared(center_delta, Vector3::default()) > 1e-18 { + radial.override_center = radial.override_center + center_delta; + radial.jog_point = radial.jog_point + center_delta; + if radial.base.text_user_positioned { + radial.base.text_middle_point = + radial.base.text_middle_point + center_delta; + radial.base.insertion_point = + radial.base.insertion_point + center_delta; + } + } + radial.base.actual_measurement = radial.measurement(); + } + Dimension::Ordinate(ordinate) => { + if let Some(feature) = resolved[0] { + ordinate.feature_location = feature; + } + ordinate.refresh_measurement(); + } + 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)); + } + refreshed + } +} diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 6d92855f..a257971e 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -586,11 +586,28 @@ impl Scene { self.images.insert(handle, model); } self.refresh_meshes_for_handles(&[handle]); - if let Some(operation) = self.document.solid_history_operation(handle).cloned() { - if let Ok(body) = cadkernel::acis::rebuild_body(&operation) { - self.solid_models.insert(handle, body); - } - } + self.restore_solid_models(&[handle]); + } + + pub fn restore_solid_models(&mut self, handles: &[Handle]) { + let bodies: Vec<(Handle, cadkernel::brep::Body)> = handles + .iter() + .filter(|handle| !self.solid_models.contains_key(handle)) + .filter_map(|&handle| { + let from_history = self + .document + .solid_history_operation(handle) + .and_then(|operation| cadkernel::acis::rebuild_body(operation).ok()); + let body = from_history.or_else(|| match self.document.get_entity(handle) { + Some(EntityType::Solid3D(solid)) => { + crate::scene::convert::solid3d_tess::kernel_body(solid) + } + _ => None, + })?; + Some((handle, body)) + }) + .collect(); + self.solid_models.extend(bodies); } /// Re-tessellate only the named ACIS entities. The former edit path @@ -924,6 +941,7 @@ impl Scene { annotation_scale_handle: Option, all_visible: bool, viewport: Option, + tint_selected: bool, ) -> Vec { let layer_hidden = |layer: &str| { self.document @@ -1098,7 +1116,7 @@ impl Scene { } } } - if self.selected.contains(&handle) { + if tint_selected && self.selected.contains(&handle) { m.color = [0.15, 0.55, 1.00, m.color[3]]; } let d = depth_map.get(&handle.value()).map_or(0.0, |d| d[0]); @@ -1115,7 +1133,7 @@ impl Scene { models.extend(self.instanced_hatch_models( target_block, hatch_bg, - true, + tint_selected, frozen, annotation_scale_handle, all_visible, @@ -2381,8 +2399,6 @@ impl Scene { let mut bits = 0; if is_outer { bits |= acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits(); - } - if !handles.is_empty() { bits |= acadrust::entities::hatch::BoundaryPathFlags::EXTERNAL.bits(); } let mut path = BoundaryPath::with_flags( diff --git a/src/scene/group_layer.rs b/src/scene/group_layer.rs index af957834..2c3aa78e 100644 --- a/src/scene/group_layer.rs +++ b/src/scene/group_layer.rs @@ -203,8 +203,11 @@ impl Scene { /// If any handle belongs to a selectable group, also select every member. pub fn expand_selection_for_groups(&mut self, handles: &[Handle]) { + let previous_len = self.selected.len(); self.selected .extend(self.handles_expanded_for_selectable_groups(handles)); - self.bump_selection(); + if self.selected.len() != previous_len { + self.bump_selection_set(); + } } } diff --git a/src/scene/layout.rs b/src/scene/layout.rs index bcf85378..ac9750a0 100644 --- a/src/scene/layout.rs +++ b/src/scene/layout.rs @@ -72,8 +72,7 @@ impl Scene { // Remove the Layout object itself. self.document.objects.remove(&layout_handle); - // Drop the layout's entry from the ACAD_LAYOUT dictionary so it does not - // dangle (and so AutoCAD doesn't try to recover a now-missing layout). + // Drop the layout's dictionary entry so it does not dangle. let dict_handle = self.document.header.acad_layout_dict_handle; if let Some(ObjectType::Dictionary(d)) = self.document.objects.get_mut(&dict_handle) { d.entries.retain(|(k, _)| k != name); @@ -90,20 +89,6 @@ impl Scene { self.document.block_records.remove(&bn); } - // Drop any standalone PlotSettings page setup tied to this layout. - let ps_handles: Vec = self - .document - .objects - .iter() - .filter_map(|(h, o)| match o { - ObjectType::PlotSettings(ps) if ps.page_name == name => Some(*h), - _ => None, - }) - .collect(); - for h in ps_handles { - self.document.objects.remove(&h); - } - // If the deleted layout was active, fall back to Model space. if self.current_layout == name { self.current_layout = "Model".to_string(); @@ -498,9 +483,7 @@ impl Scene { /// Reconstruct a `pane_grid` configuration from a set of (tile-index, rect) /// items covering `region`, by recursively finding a full vertical or -/// horizontal guillotine cut. AutoCAD tiled configs (and anything pane_grid -/// produces) are guillotine layouts, so this round-trips them. A non-guillotine -/// set falls back to chaining panes so nothing is lost. +/// horizontal guillotine cut. Non-guillotine input falls back to chained panes. fn config_from_rects( items: &[(usize, iced::Rectangle)], region: iced::Rectangle, diff --git a/src/scene/limits.rs b/src/scene/limits.rs index b6b18daa..09783637 100644 --- a/src/scene/limits.rs +++ b/src/scene/limits.rs @@ -91,6 +91,7 @@ impl Scene { self.document.header.limit_check = enabled; } else { self.document.header.paper_space_limit_check = enabled; + self.persist_current_layout_state(); } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 1f7e6f67..0894fe12 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -22,6 +22,7 @@ pub mod view; mod boundary; mod camera_ops; pub(crate) mod centerline; +pub(crate) mod dimension_assoc; pub(crate) mod centermark; mod entity; mod group_layer; @@ -518,10 +519,8 @@ pub(crate) fn wire_gpu_patch_enabled() -> bool { /// Resolve a viewport's paper-to-model scale ratio from its two /// DXF-derived sources. /// -/// `view_height` (model-space view extent) is the canonical source — it -/// is what AutoCAD actually uses to draw, and what we keep in sync on -/// every write. `custom_scale` is consulted only when `view_height` is -/// missing or zero (some third-party exporters omit it). +/// `view_height` is canonical. `custom_scale` is the fallback when it is +/// missing or zero. #[inline] pub fn vp_effective_scale(custom_scale: f64, view_height: f64, vp_height: f64) -> f64 { if view_height.abs() > 1e-9 { @@ -1487,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. @@ -1615,6 +1617,15 @@ pub struct Scene { /// movement scanned the whole document for wipeouts and recreated the same /// Arcs, making Paper frame construction CPU-bound on large drawings. paper_sheet_render_cache: RefCell>, + display_plot_style_cache: + RefCell>>>, + styled_wire_cache: RefCell< + HashMap<(u64, String), (u64, Arc>)>, + >, + styled_hatch_cache: + RefCell>>>, + styled_wire_fill_cache: + RefCell>>>, /// Per-viewport projected wire cache for paper-space content viewports. /// Stores projected + clipped wires in paper-space coordinates. /// Maps vp_handle → (geometry_epoch, Vec). @@ -1645,11 +1656,7 @@ pub struct Scene { /// owning block emits a transformed instance so a block placed at an /// INSERT scale renders at the right size. (#123) pub block_meshes: HashMap, - /// Live B-reps for solids created this session by the Model tab, keyed by - /// entity handle. Backs the Design-group boolean tools (a solid must be - /// here to be combined) and the exact-geometry save path, which writes - /// each one back out as ACIS rather than as facets. Not persisted — - /// rebuilt only by creating or combining primitives in-session. + /// Kernel B-reps used by solid operations and exact-geometry saves. pub solid_models: HashMap, /// GPU render data for raster images (RasterImage entities), keyed by handle. pub images: HashMap, @@ -1662,14 +1669,9 @@ pub struct Scene { pub bg_color: [f32; 4], /// Custom paper-space background fill color for Wipeout entities. pub paper_bg_color: [f32; 4], - /// Largest local-space coordinate expected from real geometry, derived from - /// EXTMIN/EXTMAX (10× safety margin). Used by fit_all() to ignore garbage - /// entity coordinates (origin-stuck entities, bad Ray/XLine direction vectors). + /// Dense model-space cluster half-span used for viewport recovery. pub local_extent_max: f32, - /// Robust centre (median of entity centroids) of the dense model-space - /// cluster. Used together with `local_extent_max` to frame a viewport whose - /// saved view is missing — aiming at the raw extents centre would land in - /// the empty gap when a drawing has a second, far cluster. + /// Dense model-space cluster median used for viewport recovery. pub local_center: [f64; 2], /// Current annotation scale (CANNOSCALE equivalent). /// Multiplier applied to Text/MText/Dimension sizes during tessellation. @@ -1866,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), @@ -1890,6 +1894,10 @@ impl Scene { paper_sheet_cache: RefCell::new(HashMap::default()), paper_viewport_cache: RefCell::new(HashMap::default()), paper_sheet_render_cache: RefCell::new(HashMap::default()), + display_plot_style_cache: RefCell::new(HashMap::default()), + styled_wire_cache: RefCell::new(HashMap::default()), + styled_hatch_cache: RefCell::new(HashMap::default()), + styled_wire_fill_cache: RefCell::new(HashMap::default()), paper_projected_cache: RefCell::new(HashMap::default()), current_layout: "Model".to_string(), block_edit_block: None, @@ -2406,6 +2414,11 @@ impl Scene { changes.push(change); } } + for change in self.refresh_associative_dimensions(&changes) { + if !changes.iter().any(|(handle, _)| *handle == change.0) { + changes.push(change); + } + } for change in self.refresh_associative_hatches(&changes) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); @@ -2610,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; @@ -2883,7 +2901,9 @@ impl Scene { /// active layout or its background are dropped. pub fn set_current_layout(&mut self, name: String) { if self.current_layout != name { + self.persist_current_layout_state(); self.current_layout = name; + self.load_current_layout_state(); self.sync_active_space_to_document(); self.recolor_meshes(); *self.wire_cache.borrow_mut() = None; @@ -2893,6 +2913,91 @@ impl Scene { } } + pub fn load_current_layout_state(&mut self) { + self.display_plot_style_cache + .borrow_mut() + .retain(|(layout, _), _| layout != &self.current_layout); + self.styled_wire_cache.borrow_mut().clear(); + self.styled_hatch_cache.borrow_mut().clear(); + self.styled_wire_fill_cache.borrow_mut().clear(); + if self.current_layout == "Model" { + return; + } + if let Some((flags, insertion_base, min_extents, max_extents, min_limits, max_limits)) = + self.document.objects.values().find_map(|object| { + let ObjectType::Layout(layout) = object else { + return None; + }; + (layout.name == self.current_layout).then_some(( + layout.flags, + layout.insertion_base, + layout.min_extents, + layout.max_extents, + layout.min_limits, + layout.max_limits, + )) + }) { + self.document.header.paper_space_linetype_scaling = flags & 1 != 0; + self.document.header.paper_space_limit_check = flags & 2 != 0; + self.document.header.paper_space_insertion_base = acadrust::types::Vector3::new( + insertion_base.0, + insertion_base.1, + insertion_base.2, + ); + self.document.header.paper_space_extents_min = acadrust::types::Vector3::new( + min_extents.0, + min_extents.1, + min_extents.2, + ); + self.document.header.paper_space_extents_max = acadrust::types::Vector3::new( + max_extents.0, + max_extents.1, + max_extents.2, + ); + self.document.header.paper_space_limits_min = + acadrust::types::Vector2::new(min_limits.0, min_limits.1); + self.document.header.paper_space_limits_max = + acadrust::types::Vector2::new(max_limits.0, max_limits.1); + } + } + + pub fn persist_current_layout_state(&mut self) { + if self.current_layout == "Model" { + return; + } + let header = &self.document.header; + let psltscale = header.paper_space_linetype_scaling; + let plimcheck = header.paper_space_limit_check; + let insertion_base = header.paper_space_insertion_base; + let min_extents = header.paper_space_extents_min; + let max_extents = header.paper_space_extents_max; + let min_limits = header.paper_space_limits_min; + let max_limits = header.paper_space_limits_max; + for object in self.document.objects.values_mut() { + let ObjectType::Layout(layout) = object else { + continue; + }; + if layout.name == self.current_layout { + layout.flags = if psltscale { + layout.flags | 1 + } else { + layout.flags & !1 + }; + layout.flags = if plimcheck { + layout.flags | 2 + } else { + layout.flags & !2 + }; + layout.insertion_base = (insertion_base.x, insertion_base.y, insertion_base.z); + layout.min_extents = (min_extents.x, min_extents.y, min_extents.z); + layout.max_extents = (max_extents.x, max_extents.y, max_extents.z); + layout.min_limits = (min_limits.x, min_limits.y); + layout.max_limits = (max_limits.x, max_limits.y); + break; + } + } + } + /// Mirror the active space (Model tile-mode vs a paper layout) into the /// document's persisted settings so it round-trips on save: the `$TILEMODE` /// header (`show_model_space`) and the `CTAB` current-tab variable. The @@ -2903,32 +3008,96 @@ impl Scene { crate::io::set_saved_active_layout(&mut self.document, &self.current_layout); } - /// Returns true if this viewport should display model-space content - /// (i.e. it is a user viewport, not the sheet/overall viewport). - /// - /// Rules: - /// - id=1 → always the sheet viewport → false - /// - id≥2 → always a user viewport → true - /// - id=0 or id<0 (DWG reader omits the id; some DXF exporters write -1): - /// use geometry: the sheet viewport is centred at the paper origin (0,0) - /// with scale≈1.0 (view_height ≈ paper-space height). - pub fn is_content_viewport(vp: &acadrust::entities::Viewport) -> bool { - if vp.id == 1 { - return false; + pub(crate) fn layout_sheet_viewport_handle( + document: &acadrust::CadDocument, + layout: &acadrust::objects::Layout, + ) -> Handle { + let owned_viewport = |handle| match document.get_entity(handle) { + Some(EntityType::Viewport(vp)) if vp.common.owner_handle == layout.block_record => { + Some(vp) + } + _ => None, + }; + + let listed = layout + .viewports + .iter() + .copied() + .filter_map(|handle| owned_viewport(handle).map(|vp| (handle, vp))); + if let Some((handle, _)) = listed.clone().find(|(_, vp)| vp.id == 1) { + return handle; } - if vp.id > 1 { + if let Some((handle, _)) = listed.into_iter().next() { + return handle; + } + + let block_handles = document + .block_records + .iter() + .find(|block| block.handle == layout.block_record) + .map(|block| block.entity_handles.as_slice()) + .unwrap_or_default(); + let block_viewports = block_handles + .iter() + .copied() + .filter_map(|handle| owned_viewport(handle).map(|vp| (handle, vp))); + if let Some((handle, _)) = block_viewports.clone().find(|(_, vp)| vp.id == 1) { + return handle; + } + if let Some((handle, _)) = block_viewports.into_iter().next() { + return handle; + } + + document + .entities() + .filter_map(|entity| match entity { + EntityType::Viewport(vp) if vp.common.owner_handle == layout.block_record => { + Some(( + vp.common.handle, + vp.id == 1, + vp.width.abs() * vp.height.abs(), + )) + } + _ => None, + }) + .max_by(|a, b| a.1.cmp(&b.1).then_with(|| a.2.total_cmp(&b.2))) + .map(|(handle, _, _)| handle) + .unwrap_or(Handle::NULL) + } + + pub(crate) fn is_sheet_viewport( + document: &acadrust::CadDocument, + vp: &acadrust::entities::Viewport, + ) -> bool { + if vp.id == 1 { return true; } - // id ≤ 0: DWG files never write group-code 69 (viewport id), so all - // viewports arrive with id=0. - // - // In DWG format the sheet ("overall") viewport always has its center at - // the paper-space origin (0, 0). Content viewports are placed at their - // actual position on the paper and therefore have a non-zero center. - // Using center position is more reliable than a scale heuristic because - // the sheet viewport's scale is not always exactly 1:1 (observed: 0.8965 - // in real-world files, which the old 0.02 tolerance missed entirely). - vp.center.x.abs() >= 0.5 || vp.center.y.abs() >= 0.5 + if vp.id > 1 { + return false; + } + document.objects.values().any(|object| { + matches!( + object, + ObjectType::Layout(layout) + if layout.block_record == vp.common.owner_handle + && Self::layout_sheet_viewport_handle(document, layout) + == vp.common.handle + ) + }) + } + + pub(crate) fn sheet_viewport_handles(&self) -> std::collections::HashSet { + self.document + .objects + .values() + .filter_map(|object| match object { + ObjectType::Layout(layout) => { + let handle = Self::layout_sheet_viewport_handle(&self.document, layout); + handle.is_valid().then_some(handle) + } + _ => None, + }) + .collect() } fn current_layout_sheet_viewport_handle(&self) -> Handle { @@ -2940,7 +3109,7 @@ impl Scene { return None; }; if layout.name == self.current_layout { - Some(layout.viewport) + Some(Self::layout_sheet_viewport_handle(&self.document, layout)) } else { None } @@ -2948,83 +3117,49 @@ impl Scene { .unwrap_or(Handle::NULL) } - /// Guarantee that a paper layout has its full-screen overall (`id == 1`) - /// sheet viewport. AutoCAD always writes one, and `add_layout` creates it, - /// but this is a safety net for layouts that arrive without it. The sheet - /// viewport is the authoritative paper-space view and the canvas every - /// floating viewport overlays. + /// Ensure a paper layout has its overall (`id == 1`) viewport. pub fn ensure_sheet_viewport(&mut self, layout_name: &str) { if layout_name == "Model" { return; } - // Locate the layout: its object handle, block-record handle, current - // sheet-viewport link, and paper limits. + // Locate the layout and its paper limits. let info = self.document.objects.iter().find_map(|(h, obj)| { if let ObjectType::Layout(l) = obj { if l.name == layout_name { - return Some((*h, l.block_record, l.viewport, l.min_limits, l.max_limits)); + return Some(( + *h, + l.block_record, + Self::layout_sheet_viewport_handle(&self.document, l), + l.min_limits, + l.max_limits, + )); } } None }); - let Some((layout_handle, block_record, cur_vp, min_lim, max_lim)) = info else { + let Some((layout_handle, block_record, sheet, min_lim, max_lim)) = info else { return; }; if block_record.is_null() { return; } - // Normal files carry a valid direct Layout→Viewport link. This O(1) - // path is hit on every ordinary layout-tab switch. - if cur_vp.is_valid() + if sheet.is_valid() && matches!( - self.document.get_entity(cur_vp), + self.document.get_entity(sheet), Some(EntityType::Viewport(vp)) if vp.common.owner_handle == block_record ) { return; } - // Already present? Accept either the linked viewport handle or any - // `id == 1` viewport owned by the layout block. - let has_sheet = self.document.entities().any(|e| { - matches!(e, EntityType::Viewport(vp) - if vp.common.owner_handle == block_record - && (vp.id == 1 || vp.common.handle == cur_vp)) - }); - if has_sheet { - // Keep the layout's link in sync if it was missing. - if !cur_vp.is_valid() { - let h = self.document.entities().find_map(|e| match e { - EntityType::Viewport(vp) - if vp.common.owner_handle == block_record && vp.id == 1 => - { - Some(vp.common.handle) - } - _ => None, - }); - if let Some(h) = h { - if let Some(ObjectType::Layout(l)) = - self.document.objects.get_mut(&layout_handle) - { - l.viewport = h; - } - } - } - return; - } - // Create the full-screen overall viewport covering the paper limits. let pw = (max_lim.0 - min_lim.0).abs().max(1.0); let ph = (max_lim.1 - min_lim.1).abs().max(1.0); let mut vp = acadrust::entities::Viewport::new(); vp.id = 1; vp.status = acadrust::entities::ViewportStatusFlags::default_on(); - // Paper-space center is a 2D (x, y) point with z = 0 — the same - // convention MVIEW uses for floating viewports. AutoCAD/TrueView read - // the viewport center as (x, y); putting the paper-height midpoint in z - // (with y = 0) left the sheet view centered at y = 0, shifting the whole - // layout half a page down. See issue #156. + // Paper-space center is an (x, y) point with z = 0. vp.center = acadrust::types::Vector3::new( (min_lim.0 + max_lim.0) / 2.0, (min_lim.1 + max_lim.1) / 2.0, @@ -3032,12 +3167,7 @@ impl Scene { ); vp.width = pw; vp.height = ph; - // Frame the new layout on the whole sheet: look straight down at the - // paper centre with the visible height a touch taller than the page. - // Without this the viewport keeps `Viewport::new`'s default view - // (target 0,0 / height 210), so the first time a fresh drawing's - // layout is opened the camera sits on the paper's bottom-left corner - // instead of centring the sheet. + // Frame the full sheet with a small margin. vp.view_target = acadrust::types::Vector3::new( (min_lim.0 + max_lim.0) / 2.0, (min_lim.1 + max_lim.1) / 2.0, @@ -3050,7 +3180,8 @@ impl Scene { .add_entity_to_layout(EntityType::Viewport(vp), layout_name) { if let Some(ObjectType::Layout(l)) = self.document.objects.get_mut(&layout_handle) { - l.viewport = handle; + l.viewports.retain(|candidate| *candidate != handle); + l.viewports.insert(0, handle); } } } @@ -3067,7 +3198,7 @@ impl Scene { if sheet_handle.is_valid() { vp.common.handle != sheet_handle } else { - Self::is_content_viewport(vp) + !Self::is_sheet_viewport(&self.document, vp) } } @@ -3246,8 +3377,7 @@ impl Scene { } /// Dashed rectangle marking the printable area — the paper inset by the - /// layout's plot margins. AutoCAD draws this guide on every layout; with the - /// margins now preserved we can reflect it too. `None` in model space, when + /// layout's plot margins. `None` in model space, when /// the layout has no margins, or when the inset would be degenerate. pub(super) fn printable_area_wire(&self) -> Option { if self.current_layout == "Model" { @@ -3312,33 +3442,17 @@ impl Scene { Some(wire) } - /// The effective plot settings for the current layout: a standalone - /// PlotSettings page setup if one exists, otherwise the settings embedded in - /// the LAYOUT object (paper size, margins, origin, rotation, scale). Loaded - /// AutoCAD files keep their settings embedded, so without this fallback the - /// plot/PDF path would ignore the file's rotation, origin and scale. + /// The effective plot settings embedded in the current layout. pub fn effective_plot_settings(&self) -> Option { self.plot_settings_for(&self.current_layout) } - /// Plot settings for a specific layout by name: its standalone - /// `PlotSettings` object if one exists, else synthesized from the `Layout` - /// object's embedded fields. + /// Plot settings embedded in a specific layout. pub fn plot_settings_for(&self, name: &str) -> Option { use acadrust::objects::{ ObjectType, PaperMargin, PlotPaperUnits, PlotRotation, PlotSettings, PlotType, - PlotWindow, ScaledType, + PlotWindow, ScaledType, ShadePlotMode, ShadePlotResolutionLevel, }; - if let Some(ps) = self.document.objects.values().find_map(|o| { - if let ObjectType::PlotSettings(ps) = o { - if ps.page_name.as_str() == name { - return Some(ps.clone()); - } - } - None - }) { - return Some(ps); - } self.document.objects.values().find_map(|o| { let ObjectType::Layout(l) = o else { return None; @@ -3346,10 +3460,13 @@ impl Scene { if l.name.as_str() != name { return None; } - let mut ps = PlotSettings::new(l.name.clone()); + let mut ps = PlotSettings::new(l.plot_page_name.clone()); + ps.printer_name = l.plot_printer_name.clone(); ps.paper_width = l.paper_width; ps.paper_height = l.paper_height; ps.paper_size = l.paper_size.clone(); + ps.plot_view_name = l.plot_view_name.clone(); + ps.current_style_sheet = l.plot_style_sheet.clone(); ps.margins = PaperMargin::new( l.plot_margin_left, l.plot_margin_bottom, @@ -3370,10 +3487,84 @@ impl Scene { ps.scale_type = ScaledType::from_code(l.plot_scale_type); ps.scale_numerator = l.plot_scale_numerator; ps.scale_denominator = l.plot_scale_denominator; + ps.flags = l.plot_flags; + ps.standard_scale_factor = l.plot_scale_factor; + ps.paper_image_origin_x = l.paper_image_origin_x; + ps.paper_image_origin_y = l.paper_image_origin_y; + ps.shade_plot_mode = ShadePlotMode::from_code(l.shade_plot_mode); + ps.shade_plot_resolution = + ShadePlotResolutionLevel::from_code(l.shade_plot_resolution); + ps.shade_plot_dpi = l.shade_plot_dpi; + ps.plot_view_handle = l.plot_view_handle; + ps.visual_style_handle = l.visual_style_handle; Some(ps) }) } + /// Replace the plot-settings portion embedded in one layout. + pub fn set_layout_plot_settings( + &mut self, + name: &str, + ps: &acadrust::objects::PlotSettings, + ) -> bool { + let Some(layout) = self.document.objects.values_mut().find_map(|object| { + let ObjectType::Layout(layout) = object else { + return None; + }; + (layout.name == name).then_some(layout) + }) else { + return false; + }; + + layout.plot_page_name = ps.page_name.clone(); + layout.plot_printer_name = ps.printer_name.clone(); + layout.paper_size = ps.paper_size.clone(); + layout.plot_view_name = ps.plot_view_name.clone(); + layout.plot_style_sheet = ps.current_style_sheet.clone(); + layout.plot_margin_left = ps.margins.left; + layout.plot_margin_bottom = ps.margins.bottom; + layout.plot_margin_right = ps.margins.right; + layout.plot_margin_top = ps.margins.top; + layout.paper_width = ps.paper_width; + layout.paper_height = ps.paper_height; + layout.plot_origin_x = ps.origin_x; + layout.plot_origin_y = ps.origin_y; + layout.plot_window_min_x = ps.plot_window.lower_left_x; + layout.plot_window_min_y = ps.plot_window.lower_left_y; + layout.plot_window_max_x = ps.plot_window.upper_right_x; + layout.plot_window_max_y = ps.plot_window.upper_right_y; + layout.plot_scale_numerator = ps.scale_numerator; + layout.plot_scale_denominator = ps.scale_denominator; + layout.plot_paper_units = ps.paper_units.to_code(); + layout.plot_rotation = ps.rotation.to_code(); + layout.plot_type = ps.plot_type.to_code(); + layout.plot_scale_type = ps.scale_type.to_code(); + layout.shade_plot_mode = ps.shade_plot_mode.to_code(); + layout.shade_plot_resolution = ps.shade_plot_resolution.to_code(); + layout.shade_plot_dpi = ps.shade_plot_dpi; + layout.plot_flags = ps.flags; + layout.plot_scale_factor = ps.standard_scale_factor; + layout.paper_image_origin_x = ps.paper_image_origin_x; + layout.paper_image_origin_y = ps.paper_image_origin_y; + layout.plot_view_handle = ps.plot_view_handle; + layout.visual_style_handle = ps.visual_style_handle; + layout.raw_plot_settings_codes = None; + self.display_plot_style_cache + .borrow_mut() + .retain(|(layout_name, _), _| layout_name != name); + self.styled_wire_cache.borrow_mut().clear(); + self.styled_hatch_cache.borrow_mut().clear(); + self.styled_wire_fill_cache.borrow_mut().clear(); + true + } + + pub fn invalidate_display_plot_style(&self) { + self.display_plot_style_cache.borrow_mut().clear(); + self.styled_wire_cache.borrow_mut().clear(); + self.styled_hatch_cache.borrow_mut().clear(); + self.styled_wire_fill_cache.borrow_mut().clear(); + } + /// PlotSettings store the paper size and plot margins in millimetres, but a /// layout's paper space is laid out in its own units — its viewports, /// drawing limits and the sheet the user sees can be in inches, millimetres, @@ -4401,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); } @@ -5462,6 +5654,11 @@ impl Scene { .collect() } + /// Invalidate only the draw-order depth cache without dropping geometry tessellation. + pub fn invalidate_draw_depth(&self) { + *self.draw_depth_cache.borrow_mut() = None; + } + /// Per-entity stable draw-order depth, keyed by entity handle value. /// A full build assigns sparse labels in effective draw order. Incremental /// Add/Remove then changes only the named handle: existing siblings retain @@ -5674,14 +5871,19 @@ impl Scene { arc } + #[cfg(test)] pub(super) fn hatch_models_arc(&self) -> Arc> { + self.hatch_models_arc_for_view(true) + } + + fn hatch_models_arc_for_view(&self, tint_selected: bool) -> Arc> { // Hatch models bake the selection tint (issue #71), so they depend on // the *selected set* — but NOT on hover. Keying on `selection_generation` // (which also bumps on every hover) made each hover-over a new entity // rebuild every hatch model: an O(N-hatch) stutter on hatch-heavy // drawings. Key on a signature of `selected` instead, so hover (which // never changes `selected`) keeps the cache warm. - let sel_sig = self.selected_hatch_sig(); + let sel_sig = if tint_selected { self.selected_hatch_sig() } else { 0 }; let target_block = self.content_render_block_handle(); let key = (target_block, self.current_layout.clone()); { @@ -5728,6 +5930,7 @@ impl Scene { scale, self.annotation_all_visible(), None, + tint_selected, )); self.hatch_cache.borrow_mut().insert( key, @@ -6324,9 +6527,10 @@ impl Scene { &self, viewport: Handle, frozen: &HashSet, + tint_selected: bool, ) -> Arc> { if viewport.is_null() && frozen.is_empty() { - return self.hatch_models_arc(); + return self.hatch_models_arc_for_view(tint_selected); } let target_block = self.content_render_block_handle(); let scale = self.viewport_scale_handle(viewport); @@ -6336,7 +6540,7 @@ impl Scene { ^ self.viewport_style_key(Some(viewport)).rotate_left(23); let sig = Self::frozen_layers_sig(frozen) ^ context_sig; let key = (target_block, self.current_layout.clone(), sig); - let sel = self.selected_hatch_sig(); + let sel = if tint_selected { self.selected_hatch_sig() } else { 0 }; if let Some((e, s, arc)) = self.frozen_hatch_cache.borrow().get(&key) { if *e == self.geometry_epoch && *s == sel { return Arc::clone(arc); @@ -6348,6 +6552,7 @@ impl Scene { scale, all_visible, Some(viewport), + tint_selected, )); self.frozen_hatch_cache .borrow_mut() @@ -7965,6 +8170,44 @@ impl Scene { ) } + pub fn solid_click_point_for( + &self, + cursor: iced::Point, + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + target: Handle, + ) -> Option { + let meshes = self.interaction_meshes_arc(); + pick::hit_test::mesh_click_point( + cursor, + meshes.iter().filter_map(|set| { + (set.entity_handle()? == target).then_some(())?; + let mesh = set.geometry_lods().first()?; + Some(( + target, + mesh, + set.instance_transform, + mesh_interaction_aabb(set)?, + )) + }), + view_rot, + eye, + bounds, + ) + } + + pub fn solid_planar_face_normal_at( + &mut self, + handle: Handle, + pick: glam::DVec3, + ) -> Option { + self.restore_solid_models(&[handle]); + let body = self.solid_models.get(&handle)?; + let face = model::solid_model::nearest_planar_face(body, pick.to_array())?; + model::solid_model::planar_face_normal(body, face).map(glam::DVec3::from_array) + } + pub fn view_center_surface_pivot( &self, bounds: iced::Rectangle, @@ -9365,9 +9608,7 @@ impl Scene { if any { return Some((min, max)); } - // Last-resort: the header's saved EXTMIN/EXTMAX. AutoCAD writes these - // on save so opening a file gives ZOOM EXTENTS a useful answer before - // the wire cache is built. + // Last resort: saved EXTMIN/EXTMAX before the wire cache is built. const SANE_EXTENT: f64 = 1.0e16; let h = &self.document.header; let hmin = h.model_space_extents_min; diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index f0f13c93..1ae480b4 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -139,6 +139,39 @@ impl GradientKind { } } +pub(crate) fn plot_style_fill_pattern(style: u8) -> Option { + let family = |angle_deg, dx, dy, dashes: &[f32]| PatFamily { + angle_deg, + x0: 0.0, + y0: 0.0, + dx, + dy, + dashes: dashes.to_vec(), + }; + let pattern = match style { + 64 => HatchPattern::Solid, + 65 => HatchPattern::Pattern(vec![ + family(0.0, 0.0, 2.0, &[2.0, -2.0]), + family(90.0, 2.0, 0.0, &[2.0, -2.0]), + ]), + 66 => HatchPattern::Pattern(vec![ + family(0.0, 0.0, 2.0, &[]), + family(90.0, 2.0, 0.0, &[]), + ]), + 67 => HatchPattern::Pattern(vec![ + family(45.0, 0.0, 2.0, &[]), + family(135.0, 0.0, 2.0, &[]), + ]), + 68 => HatchPattern::Pattern(vec![family(0.0, 0.0, 2.0, &[])]), + 69 => HatchPattern::Pattern(vec![family(135.0, 0.0, 2.0, &[])]), + 70 => HatchPattern::Pattern(vec![family(45.0, 0.0, 2.0, &[])]), + 71 => HatchPattern::Pattern(vec![family(0.0, 0.0, 2.0, &[0.0, -2.0])]), + 72 => HatchPattern::Pattern(vec![family(90.0, 2.0, 0.0, &[])]), + _ => return None, + }; + Some(pattern) +} + /// Hatch fill pattern. #[derive(Clone, Debug)] pub enum HatchPattern { diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index f688072c..613a82f3 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -8,6 +8,8 @@ use glam::DVec3; pub enum PropValue { /// Read-only display text. ReadOnly(String), + /// Read-only display text with a tooltip explaining why it cannot be edited. + ReadOnlyWithTooltip { value: String, tooltip: String }, /// Editable numeric field. EditText(String), /// Editable text that must not be expression-evaluated. diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index bd143587..9879bd2d 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -1,11 +1,9 @@ -use acadrust::entities::{EmbeddedEntity, Solid3D}; +use acadrust::entities::Solid3D; use acadrust::objects::{ - SolidHistoryBox, SolidHistoryBrep, SolidHistoryCylinder, SolidHistoryLoft, + SolidHistoryBox, SolidHistoryBrep, SolidHistoryCylinder, SolidHistoryNodeBase, SolidHistoryOperation, SolidHistoryPyramid, - SolidHistoryRevolve, SolidHistorySphere, SolidHistorySweep, SolidHistoryTorus, + SolidHistorySphere, SolidHistoryTorus, }; -use acadrust::types::{Vector2, Vector3}; -use acadrust::EntityType; use cadkernel::brep::Body; use crate::command::EntityTransform; @@ -474,21 +472,6 @@ fn base(transform: [f64; 16]) -> SolidHistoryNodeBase { base } -fn embedded(entity: &EntityType) -> Option { - Some(match entity { - EntityType::Point(value) => EmbeddedEntity::Point(value.clone()), - EntityType::Line(value) => EmbeddedEntity::Line(value.clone()), - EntityType::Arc(value) => EmbeddedEntity::Arc(value.clone()), - EntityType::Circle(value) => EmbeddedEntity::Circle(value.clone()), - EntityType::Ellipse(value) => EmbeddedEntity::Ellipse(value.clone()), - EntityType::Spline(value) => EmbeddedEntity::Spline(value.clone()), - EntityType::LwPolyline(value) => EmbeddedEntity::LwPolyline(value.clone()), - EntityType::Ray(value) => EmbeddedEntity::Ray(value.clone()), - EntityType::XLine(value) => EmbeddedEntity::XLine(value.clone()), - _ => return None, - }) -} - pub fn box_op( transform: [f64; 16], length: f64, @@ -593,7 +576,7 @@ pub fn pyramid_op( } pub fn brep_op(body: &Body) -> SolidHistoryOperation { - let acis_data = crate::scene::convert::acis_export::planar_solid_to_sat(body) + let acis_data = crate::scene::convert::acis_export::solid_to_sat(body) .map(|document| { let mut solid = Solid3D::new(); solid.set_sat_document(&document); @@ -607,56 +590,3 @@ pub fn brep_op(body: &Body) -> SolidHistoryOperation { ..SolidHistoryBrep::default() }) } - -pub fn extrusion_op(profile: &EntityType, height: f64) -> SolidHistoryOperation { - SolidHistoryOperation::Extrusion(SolidHistorySweep { - base: base(glam::DMat4::IDENTITY.to_cols_array()), - operation_major: 1, - direction: Vector3::new(0.0, 0.0, height), - sweep_entity: embedded(profile), - scale_factor: 1.0, - sweep_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), - path_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), - ..SolidHistorySweep::default() - }) -} - -pub fn sweep_op(profile: &EntityType, path: &EntityType) -> SolidHistoryOperation { - SolidHistoryOperation::Sweep(SolidHistorySweep { - base: base(glam::DMat4::IDENTITY.to_cols_array()), - operation_major: 1, - sweep_entity: embedded(profile), - path_entity: embedded(path), - scale_factor: 1.0, - sweep_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), - path_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), - ..SolidHistorySweep::default() - }) -} - -pub fn loft_op(profiles: &[EntityType]) -> SolidHistoryOperation { - SolidHistoryOperation::Loft(SolidHistoryLoft { - base: base(glam::DMat4::IDENTITY.to_cols_array()), - operation_major: 1, - cross_sections: profiles.iter().filter_map(embedded).collect(), - ..SolidHistoryLoft::default() - }) -} - -pub fn revolve_op( - profile: &EntityType, - axis_start: [f64; 3], - axis_end: [f64; 3], - angle: f64, -) -> SolidHistoryOperation { - let direction = glam::DVec3::from_array(axis_end) - glam::DVec3::from_array(axis_start); - SolidHistoryOperation::Revolve(SolidHistoryRevolve { - base: base(glam::DMat4::IDENTITY.to_cols_array()), - operation_major: 1, - axis_point: Vector3::new(axis_start[0], axis_start[1], axis_start[2]), - direction: Vector2::new(direction.x, direction.y), - revolve_angle: angle, - sweep_entity: embedded(profile), - ..SolidHistoryRevolve::default() - }) -} diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 15d2fa11..b9d7f5db 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -1,19 +1,6 @@ -// B-rep construction for the Model tab's primitives, plus tessellation into -// the renderer's `MeshLodSet`. -// -// The bodies come from the geometry kernel, which builds each primitive the -// way ACIS records it: an analytic surface with singular vertices where the -// surface has them, rather than a mesh or a spline that happens to look like -// one. That is what lets a solid built here be written back out as exact -// geometry instead of a facetted approximation — see `acis_bridge`. -// -// Everything is oriented Z-up with the footprint on the z = base plane, to -// match acadrust's `acis::primitives`. -// -// The resulting `Body` is cached per entity handle on the Scene so the -// Design-group boolean tools can run on it. +// Kernel B-rep construction and display tessellation. -use cadkernel::brep::{self, Body}; +use cadkernel::brep::{self, Body, Curve3, EdgeKey, FaceKey, Surface}; use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; @@ -223,6 +210,65 @@ pub fn edge_wires(body: &Body) -> Vec { .collect() } +/// B-rep edge nearest a world-space surface pick. +pub fn nearest_edge(body: &Body, pick: [f64; 3]) -> Option { + let pick = cadkernel::space::Vec3::from(pick); + body.edge_keys() + .filter_map(|key| { + let edge = body.edges.get(key)?; + let curve = body.curves.get(edge.curve)?; + let nearest = if matches!(curve, Curve3::Line(_)) { + let start = cadkernel::space::Vec3::from(curve.point_at(edge.start_parameter)); + let end = cadkernel::space::Vec3::from(curve.point_at(edge.end_parameter)); + let span = end - start; + let length2 = span.dot(span); + let along = if length2 > 0.0 { + (pick - start).dot(span) / length2 + } else { + 0.0 + } + .clamp(0.0, 1.0); + pick.distance(start + span * along) + } else { + (0..=64) + .map(|step| { + let t = edge.start_parameter + + (edge.end_parameter - edge.start_parameter) * step as f64 / 64.0; + pick.distance(cadkernel::space::Vec3::from(curve.point_at(t))) + }) + .fold(f64::INFINITY, f64::min) + }; + Some((key, nearest)) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(key, _)| key) +} + +/// Planar face nearest a world-space surface pick. +pub fn nearest_planar_face(body: &Body, pick: [f64; 3]) -> Option { + let face = body + .face_keys() + .filter_map(|key| { + let face = body.faces.get(key)?; + let surface = body.surfaces.get(face.surface)?; + Some((key, surface.distance_to(pick).abs())) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(key, _)| key)?; + let node = body.faces.get(face)?; + matches!(body.surfaces.get(node.surface)?, Surface::Plane(_)).then_some(face) +} + +/// Outward normal of a planar face. +pub fn planar_face_normal(body: &Body, face: FaceKey) -> Option<[f64; 3]> { + let face = body.faces.get(face)?; + let Surface::Plane(plane) = body.surfaces.get(face.surface)? else { + return None; + }; + let normal = cadkernel::space::Vec3::from(plane.normal()?); + Some(if face.forward { normal } else { -normal }.to_array()) +} + // ── Boolean operations ────────────────────────────────────────────────────── /// Which CSG to apply. Mirrors `model::boolean_cmd::BoolOp` but kept local so @@ -246,17 +292,12 @@ pub fn boolean(op: Bool, a: &Body, b: &Body) -> Option { Bool::Subtract => brep::Operation::Difference, Bool::Intersect => brep::Operation::Intersection, }; - brep::combine(a.clone(), b.clone(), how, TOL).ok() + let tolerance = brep::operation_tolerance(&[a, b]); + brep::combine(a.clone(), b.clone(), how, tolerance).ok() } // ── Tessellation ──────────────────────────────────────────────────────────── -/// Tessellate a `Body` into a single-LOD `MeshLodSet` (world-space, before -/// world_offset is applied by the caller). -pub fn mesh_from_solid(body: &Body, color: [f32; 4]) -> Option { - mesh_from_tessellation(tessellation(body), color) -} - fn mesh_from_tessellation( tessellation: brep::mesh::BodyMesh, color: [f32; 4], @@ -400,8 +441,8 @@ mod tests { use super::*; fn tri_count(body: &Body) -> usize { - mesh_from_solid(body, [0.7, 0.7, 0.7, 1.0]) - .map(|m| m.lods[0].indices.len() / 3) + display_from_solid(body, [0.7, 0.7, 0.7, 1.0]) + .map(|(m, _, _)| m.lods[0].indices.len() / 3) .unwrap_or(0) } diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index f79e048c..124e53fd 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -1,20 +1,8 @@ -// EXTRUDE / REVOLVE: turning a drawn profile into a solid. -// -// The profile comes from `entities::curve`, which is where every entity's -// geometry is already defined once, so a circle, an arc-bulged polyline and a -// closed spline all arrive as the same thing: a plane plus a chain of curves -// in that plane's coordinates. The kernel sweeps that chain into analytic -// surfaces — a straight run into a plane, an arc into a cylinder, a profile -// turned about an axis into a cone, a sphere or a torus — so the result can -// be written back out as ACIS rather than as facets. -// -// A spline profile has no analytic side wall, so it is refused rather than -// approximated into a surface nobody asked for. That is the kernel's answer -// and this module passes it on. +// Exact profile sweeps stored as kernel B-reps and ACIS. use cadkernel::brep::{self, Body}; -use cadkernel::geom2d::Curve; -use cadkernel::space::{PlanarCurve, Plane}; +use cadkernel::geom2d::{offset_polyline, Arc, Curve, EllipseArc, Line, Polyline}; +use cadkernel::space::{PlanarCurve, Plane, Vec3}; use acadrust::EntityType; use crate::entities::curve::entity_curve; @@ -40,13 +28,17 @@ pub fn profile_of(entity: &EntityType) -> Option { return None; } let pieces = match &planar.curve { - // A polyline is already a chain. Its own segments carry the bulges, - // so an arc stays an arc rather than becoming a run of chords. Curve::Polyline(_) => planar.curve.segments(), - // Everything else closed on itself is cut into quarters, which is - // both the fewest pieces a chain may have and the fewest that leave - // each one unambiguous about which way round it goes. Curve::Circle(circle) => quarters(circle.centre, circle.radius), + Curve::Arc(arc) => split_arc(*arc), + Curve::Ellipse(arc) => split_ellipse(*arc), + Curve::Nurbs(curve) => (0..4) + .map(|part| { + curve + .trimmed(part as f64 / 4.0, (part + 1) as f64 / 4.0) + .map(Curve::Nurbs) + }) + .collect::>>()?, _ => return None, }; (pieces.len() >= 3).then_some(Profile { @@ -57,7 +49,6 @@ pub fn profile_of(entity: &EntityType) -> Option { /// A circle as four arcs. fn quarters(centre: [f64; 2], radius: f64) -> Vec { - use cadkernel::geom2d::Arc; use std::f64::consts::FRAC_PI_2; (0..4) .map(|quarter| { @@ -72,6 +63,34 @@ fn quarters(centre: [f64; 2], radius: f64) -> Vec { .collect() } +fn split_arc(arc: Arc) -> Vec { + let start = arc.start_angle; + let step = arc.sweep() / 4.0; + (0..4) + .map(|part| { + Curve::Arc(Arc { + start_angle: start + step * part as f64, + end_angle: start + step * (part + 1) as f64, + ..arc + }) + }) + .collect() +} + +fn split_ellipse(arc: EllipseArc) -> Vec { + let start = arc.start_parameter; + let step = arc.sweep() / 4.0; + (0..4) + .map(|part| { + Curve::Ellipse(EllipseArc { + ellipse: arc.ellipse, + start_parameter: start + step * part as f64, + end_parameter: start + step * (part + 1) as f64, + }) + }) + .collect() +} + /// EXTRUDE: drag the profile `height` along its own plane's normal. /// /// `None` for a profile that does not close, encloses nothing, or holds a @@ -86,6 +105,13 @@ pub fn extruded(entity: &EntityType, height: f64) -> Option { ) } +/// Signed distance of a drag along a profile's normal. +pub fn projected_drag(entity: &EntityType, from: glam::DVec3, to: glam::DVec3) -> Option { + let normal = entity_curve(entity)?.plane.normal()?; + let distance = (to - from).dot(glam::DVec3::from_array(normal)); + (distance.is_finite() && distance.abs() > 1e-6).then_some(distance) +} + /// REVOLVE: turn the profile about the axis from `from` to `to` by `angle` /// radians. /// @@ -108,85 +134,191 @@ pub fn revolved( ) } -use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; - -/// SWEEP through the kernel's tolerance-driven mesh API. -pub fn swept(profile: &EntityType, path: &EntityType, color: [f32; 4]) -> Option { - let max_angle = cadkernel::tessellation::DEFAULT_ANGLE; - let surface = brep::mesh::sweep_surface( - &entity_curve(profile)?, - &entity_curve(path)?, - max_angle, - )?; - mesh_set(surface, color, 1e-9) +/// SWEEP as an exact B-rep along straight and circular path pieces. +pub fn swept(profile: &EntityType, path: &EntityType) -> Option { + let profile = profile_of(profile)?; + let path = entity_curve(path)?; + let pieces = match &path.curve { + Curve::Line(line) => vec![Curve::Line(*line)], + Curve::Arc(arc) => vec![Curve::Arc(*arc)], + Curve::Polyline(_) => path.curve.segments(), + _ => return None, + }; + brep::sweep_along(profile.plane, &profile.pieces, path.plane, &pieces) } -/// LOFT through the kernel's tolerance-driven mesh API. -pub fn lofted(profiles: &[EntityType], color: [f32; 4]) -> Option { - let curves: Vec = profiles.iter().filter_map(entity_curve).collect(); - let max_angle = cadkernel::tessellation::DEFAULT_ANGLE; - mesh_set(brep::mesh::loft_surface(&curves, max_angle)?, color, 1e-9) +/// LOFT as an exact B-rep through compatible closed sections. +pub fn lofted(profiles: &[EntityType]) -> Option { + if let Some(body) = circular_loft(profiles) { + return Some(body); + } + let sections = profiles + .iter() + .map(|entity| { + let profile = profile_of(entity)?; + Some((profile.plane, profile.pieces)) + }) + .collect::>>()?; + brep::loft(§ions) } -fn mesh_set( - surface: brep::mesh::SurfaceMesh, - color: [f32; 4], - precision: f64, -) -> Option { - if surface.mesh.is_empty() { +/// Builds an exact wall solid around a polyline centreline. +pub fn polysolid(entity: &EntityType, width: f64, height: f64) -> Option { + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height.abs() <= 1e-12 { return None; } - let mut verts = Vec::with_capacity(surface.mesh.positions.len()); - let mut verts_low = Vec::with_capacity(surface.mesh.positions.len()); - for point in &surface.mesh.positions { - push_point(&mut verts, &mut verts_low, *point); + let planar = entity_curve(entity)?; + let Curve::Polyline(source) = planar.curve else { + return None; + }; + let (left_pick, right_pick) = offset_picks(&source, width)?; + let mut left = offset_polyline(&source, width * 0.5, left_pick); + let mut right = offset_polyline(&source, width * 0.5, right_pick); + if left.len() != 1 || right.len() != 1 { + return None; } - let silhouette = surface.silhouette_source(precision); - let mut set = MeshLodSet::from_single(MeshModel { - name: String::new(), - verts, - verts_low, - normals: surface - .mesh - .normals - .iter() - .map(|normal| [normal[0] as f32, normal[1] as f32, normal[2] as f32]) - .collect(), - indices: surface - .mesh - .triangles - .iter() - .flatten() - .map(|index| *index as u32) - .collect(), - triangle_material_handles: Vec::new(), - triangle_colors: Vec::new(), - color, - selected: false, - }); - { - let (high, low) = (&mut set.edge_verts, &mut set.edge_verts_low); - for edge in surface.edges { - for segment in edge.windows(2) { - for point in segment { - push_point(high, low, *point); - } - } + let left = left.remove(0); + let right = right.remove(0); + let normal = Vec3::from(planar.plane.normal()?); + let direction = (normal * height).to_array(); + + if source.closed { + if !left.closed || !right.closed { + return None; } + let mut profiles = [left, right] + .into_iter() + .map(|polyline| { + let area = Curve::Polyline(polyline.clone()).enclosed_area().abs(); + (area, Curve::Polyline(polyline).segments()) + }) + .collect::>(); + profiles.sort_by(|first, second| second.0.total_cmp(&first.0)); + if profiles[0].0 <= profiles[1].0 || profiles[1].0 <= 1e-12 { + return None; + } + return brep::extrude_region( + planar.plane, + &[profiles.remove(0).1, profiles.remove(0).1], + direction, + ); } - set.curved_gens - .push(super::mesh_model::CurvedGen { source: silhouette }); - Some(set) + + if left.closed || right.closed { + return None; + } + let left_start = left.vertices.first()?.position; + let left_end = left.vertices.last()?.position; + let right_start = right.vertices.first()?.position; + let right_end = right.vertices.last()?.position; + let mut outline = Curve::Polyline(left).segments(); + outline.push(Curve::Line(Line { + start: left_end, + end: right_end, + })); + let mut back = Curve::Polyline(right).segments(); + back.reverse(); + outline.extend(back); + outline.push(Curve::Line(Line { + start: right_start, + end: left_start, + })); + brep::extrude(planar.plane, &outline, direction) } -fn push_point(high: &mut Vec<[f32; 3]>, low: &mut Vec<[f32; 3]>, point: [f64; 3]) { - let coarse = [point[0] as f32, point[1] as f32, point[2] as f32]; - high.push(coarse); - low.push([ - (point[0] - coarse[0] as f64) as f32, - (point[1] - coarse[1] as f64) as f32, - (point[2] - coarse[2] as f64) as f32, - ]); +fn offset_picks(polyline: &Polyline, width: f64) -> Option<([f64; 2], [f64; 2])> { + let start = polyline.vertices.first()?.position; + let next = polyline.vertices.get(1)?.position; + let along = if let Some(arc) = polyline.segment_arc(0) { + let near = arc.sample(1e-6); + [near[0] - start[0], near[1] - start[1]] + } else { + [next[0] - start[0], next[1] - start[1]] + }; + let length = along[0].hypot(along[1]); + if length <= 1e-12 { + return None; + } + let side = [-along[1] / length * width, along[0] / length * width]; + Some(( + [start[0] + side[0], start[1] + side[1]], + [start[0] - side[0], start[1] - side[1]], + )) +} + +fn circular_loft(profiles: &[EntityType]) -> Option { + if profiles.len() < 2 { + return None; + } + let sections = profiles + .iter() + .map(|entity| { + let planar = entity_curve(entity)?; + let Curve::Circle(circle) = planar.curve else { + return None; + }; + Some(( + Vec3::from(planar.plane.point_at(circle.centre)), + circle.radius, + Vec3::from(planar.plane.normal()?), + Vec3::from(planar.plane.x_axis), + )) + }) + .collect::>>()?; + let first = sections.first()?; + let direction = (sections.last()?.0 - first.0).normalize()?; + let scale = sections + .iter() + .map(|(centre, radius, _, _)| centre.length().max(*radius)) + .fold(1.0_f64, f64::max); + let tolerance = scale * 1e-9; + let mut heights = Vec::with_capacity(sections.len()); + for (centre, radius, normal, _) in §ions { + if !radius.is_finite() + || *radius <= tolerance + || normal.dot(direction).abs() < 1.0 - 1e-9 + { + return None; + } + let offset = *centre - first.0; + if (offset - direction * offset.dot(direction)).length() > tolerance { + return None; + } + heights.push(offset.dot(direction)); + } + if heights + .windows(2) + .any(|pair| pair[1] <= pair[0] + tolerance) + { + return None; + } + let radial_seed = first.3 - direction * first.3.dot(direction); + let radial = radial_seed.normalize()?; + let profile_plane = Plane::from_axes(first.0.to_array(), radial.to_array(), direction.to_array()); + let mut points = Vec::with_capacity(sections.len() + 2); + points.push([0.0, 0.0]); + points.extend( + sections + .iter() + .zip(&heights) + .map(|((_, radius, _, _), height)| [*radius, *height]), + ); + points.push([0.0, *heights.last()?]); + let profile = (0..points.len()) + .map(|index| { + Curve::Line(Line { + start: points[index], + end: points[(index + 1) % points.len()], + }) + }) + .collect::>(); + brep::revolve( + profile_plane, + &profile, + first.0.to_array(), + direction.to_array(), + std::f64::consts::TAU, + ) } #[cfg(test)] diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index 5e16de1f..bc5549f4 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -59,6 +59,72 @@ pub struct PointMarker { pub viewport_percent: f32, } +#[derive(Clone, Copy, Debug)] +pub struct PatternStationPiece { + pub source_segment: u32, + pub source_distances: [f32; 2], + pub segment_distances: [f32; 2], + pub vector: [f32; 3], +} + +#[derive(Clone, Debug)] +pub(crate) struct PatternStationMap { + pub point_segments: Vec, + pub pieces: Vec, +} + +pub(crate) fn pattern_station_values(data: &[f32], point_count: usize) -> Option<(&[f32], f32)> { + (data.len() >= point_count + 1).then(|| (&data[..point_count], data[point_count])) +} + +pub(crate) fn encode_pattern_stations( + mut stations: Vec, + source_length: f32, + point_segments: &[i32], + pieces: &[PatternStationPiece], +) -> Vec { + let point_count = stations.len(); + stations.push(source_length); + if point_segments.len() != point_count || pieces.is_empty() { + return stations; + } + stations.extend(point_segments.iter().map(|segment| *segment as f32)); + for piece in pieces { + stations.push(piece.source_segment as f32); + stations.extend(piece.source_distances); + stations.extend(piece.segment_distances); + stations.extend(piece.vector); + } + stations +} + +pub(crate) fn decode_pattern_station_map( + data: &[f32], + point_count: usize, +) -> Option { + let metadata_start = point_count.checked_mul(2)?.checked_add(1)?; + if data.len() < metadata_start || (data.len() - metadata_start) % 8 != 0 { + return None; + } + let point_segments = data[point_count + 1..metadata_start] + .iter() + .map(|value| *value as i32) + .collect(); + let pieces = data[metadata_start..] + .chunks_exact(8) + .map(|values| PatternStationPiece { + source_segment: values[0] as u32, + source_distances: [values[1], values[2]], + segment_distances: [values[3], values[4]], + vector: [values[5], values[6], values[7]], + }) + .collect(); + Some(PatternStationMap { + point_segments, + pieces, + }) +} + impl PointMarker { pub fn resolve(self, point: glam::DVec3, view_height: f64) -> glam::DVec3 { let normal = self.normal.normalize_or(glam::DVec3::Z); @@ -111,6 +177,8 @@ pub struct WireModel { /// constant band of `world_width`. The wire shader reads the two endpoint /// widths of each segment and interpolates, so the band tapers smoothly. pub taper_widths: Vec, + /// Centreline stations and optional source-piece transform metadata. + pub pattern_stations: Vec, /// ACI color index (1-255). 0 means true-color or unknown (no CTB lookup). pub aci: u8, /// Pre-baked snap candidates (Center, Node, Quadrant, Insertion). @@ -249,6 +317,7 @@ impl WireModel { Self { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -625,6 +694,7 @@ impl Default for WireModel { line_weight_px: 1.0, world_width: 0.0, taper_widths: Vec::new(), + pattern_stations: Vec::new(), aci: 0, snap_pts: Vec::new(), tangent_geoms: Vec::new(), diff --git a/src/scene/modify.rs b/src/scene/modify.rs index c215ef12..95892900 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -807,7 +807,7 @@ impl Scene { let Ok(body) = cadkernel::acis::rebuild_body(&operation) else { return false; }; - let Some(document) = crate::scene::convert::acis_export::planar_solid_to_sat(&body) else { + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { return false; }; self.record_solid_history_before(handle); @@ -833,7 +833,7 @@ impl Scene { let Ok(body) = cadkernel::acis::rebuild_body(&operation) else { return false; }; - let Some(document) = crate::scene::convert::acis_export::planar_solid_to_sat(&body) else { + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { return false; }; let Some(EntityType::Solid3D(entity)) = self.document.get_entity_mut(handle) else { diff --git a/src/scene/paper.rs b/src/scene/paper.rs index a3c11612..d7a1d928 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -29,7 +29,7 @@ impl Scene { && if sheet.is_valid() { handle != sheet } else { - Self::is_content_viewport(vp) + !Self::is_sheet_viewport(&self.document, vp) } }; let content = if let Some(block) = self @@ -168,7 +168,6 @@ impl Scene { if !vp.status.is_on || vp.common.invisible || self.entity_temporarily_hidden(handle) - || self.layer_hidden(&vp.common.layer) { continue; } @@ -204,7 +203,18 @@ impl Scene { Arc>, Arc>, ) { - let selected = self.selected_hatch_sig(); + self.paper_sheet_render_models_for_view(true) + } + + pub(super) fn paper_sheet_render_models_for_view( + &self, + tint_selected: bool, + ) -> ( + Arc>, + Arc>, + Arc>, + ) { + let selected = if tint_selected { self.selected_hatch_sig() } else { 0 }; let reuse = { let cache = self.paper_sheet_render_cache.borrow(); if let Some(cache) = cache.get(&self.current_layout) { @@ -259,7 +269,11 @@ impl Scene { if let Some(sheet) = self.paper_sheet_fill() { hatches.push(sheet); } - hatches.extend(self.paper_canvas_hatches().iter().cloned()); + hatches.extend( + self.paper_canvas_hatches(tint_selected) + .iter() + .cloned(), + ); let hatches = Arc::new(hatches); let wipeouts = self.paper_canvas_wipeouts(); let images = self.paper_sheet_images(); @@ -381,7 +395,7 @@ impl Scene { /// entity handle) rather than the already-flattened arc — the /// flattened arc carries pattern names, not handles, so filtering /// there is unreliable. - pub fn paper_canvas_hatches(&self) -> Arc> { + fn paper_canvas_hatches(&self, tint_selected: bool) -> Arc> { let layout_block = self.current_layout_block_handle(); let layer_hidden = |layer: &str| { self.document @@ -455,7 +469,7 @@ impl Scene { } } } - if self.selected.contains(&handle) { + if tint_selected && self.selected.contains(&handle) { m.color = [0.15, 0.55, 1.00, m.color[3]]; } models.push(m); diff --git a/src/scene/pick/xclip.rs b/src/scene/pick/xclip.rs index 2a060f13..40786c91 100644 --- a/src/scene/pick/xclip.rs +++ b/src/scene/pick/xclip.rs @@ -16,7 +16,9 @@ use acadrust::types::{Handle, Transform, Vector3}; use acadrust::CadDocument; use cadkernel::geom2d::{contains, Curve, Line, Tolerance}; -use crate::scene::model::wire_model::WireModel; +use crate::scene::model::wire_model::{ + decode_pattern_station_map, encode_pattern_stations, pattern_station_values, WireModel, +}; const NAN3: [f32; 3] = [f32::NAN, f32::NAN, f32::NAN]; @@ -144,6 +146,7 @@ pub fn clip_wires(wires: &mut Vec, poly: &[[f64; 2]]) { if keep_marker == Some(false) { w.points.clear(); w.points_low.clear(); + w.pattern_stations.clear(); w.point_marker = None; } else if keep_marker.is_none() && !w.points.is_empty() { // Absolute → local f32 (NaN separators preserved). @@ -158,7 +161,15 @@ pub fn clip_wires(wires: &mut Vec, poly: &[[f64; 2]]) { [(a[0] - rx) as f32, (a[1] - ry) as f32, a[2] as f32] }) .collect(); - let clipped = clip_polyline(&local, &lpoly); + let station_data = pattern_station_values(&w.pattern_stations, w.points.len()); + let source_length = station_data.map(|(_, source_length)| source_length); + let station_map = decode_pattern_station_map(&w.pattern_stations, w.points.len()); + let (clipped, clipped_stations, clipped_segments) = clip_polyline_stationed( + &local, + &lpoly, + station_data.map(|(stations, _)| stations), + station_map.as_ref().map(|map| map.point_segments.as_slice()), + ); // Local → absolute, re-split into double-single high/low. let mut hi = Vec::with_capacity(clipped.len()); let mut lo = Vec::with_capacity(clipped.len()); @@ -176,6 +187,16 @@ pub fn clip_wires(wires: &mut Vec, poly: &[[f64; 2]]) { } w.points = hi; w.points_low = lo; + if let Some(source_length) = source_length { + w.pattern_stations = encode_pattern_stations( + clipped_stations, + source_length, + &clipped_segments, + station_map.as_ref().map_or(&[], |map| map.pieces.as_slice()), + ); + } else { + w.pattern_stations.clear(); + } } if !w.fill_tris.is_empty() { let local: Vec<[f32; 3]> = (0..w.fill_tris.len()) @@ -277,6 +298,7 @@ pub fn frame_wire( WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -438,8 +460,20 @@ fn point_in_poly(x: f32, y: f32, poly: &[[f32; 2]]) -> bool { /// Clip a NaN-separated polyline to `poly`, returning a NaN-separated polyline /// of only the inside portions. +#[cfg(test)] fn clip_polyline(pts: &[[f32; 3]], poly: &[[f32; 2]]) -> Vec<[f32; 3]> { + clip_polyline_stationed(pts, poly, None, None).0 +} + +fn clip_polyline_stationed( + pts: &[[f32; 3]], + poly: &[[f32; 2]], + stations: Option<&[f32]>, + point_segments: Option<&[i32]>, +) -> (Vec<[f32; 3]>, Vec, Vec) { let mut out: Vec<[f32; 3]> = Vec::new(); + let mut out_stations = Vec::new(); + let mut out_segments = Vec::new(); let mut i = 0; while i < pts.len() { if !pts[i][0].is_finite() || !pts[i][1].is_finite() { @@ -453,32 +487,80 @@ fn clip_polyline(pts: &[[f32; 3]], poly: &[[f32; 2]]) -> Vec<[f32; 3]> { let seg = &pts[start..i]; let mut last: Option<[f32; 3]> = None; for j in 0..seg.len().saturating_sub(1) { - for (a, b) in clip_segment(seg[j], seg[j + 1], poly) { + for (ta, tb) in clip_segment_params(seg[j], seg[j + 1], poly) { + let lerp_point = |t: f32| { + [ + seg[j][0] + (seg[j + 1][0] - seg[j][0]) * t, + seg[j][1] + (seg[j + 1][1] - seg[j][1]) * t, + seg[j][2] + (seg[j + 1][2] - seg[j][2]) * t, + ] + }; + let a = lerp_point(ta); + let b = lerp_point(tb); + let station = |t: f32| { + stations.map_or(0.0, |values| { + let a = values[start + j]; + a + (values[start + j + 1] - a) * t + }) + }; let contiguous = last.is_some_and(|l| { (l[0] - a[0]).abs() <= 1e-4 && (l[1] - a[1]).abs() <= 1e-4 }); if !contiguous { if !out.is_empty() { out.push(NAN3); + if stations.is_some() { + out_stations.push(0.0); + } + if point_segments.is_some() { + out_segments.push(-1); + } } out.push(a); + if stations.is_some() { + out_stations.push(station(ta)); + } + if let Some(segments) = point_segments { + out_segments.push(segments[start + j]); + } } out.push(b); + if stations.is_some() { + out_stations.push(station(tb)); + } + if let Some(segments) = point_segments { + out_segments.push(segments[start + j]); + } last = Some(b); } } } - out + (out, out_stations, out_segments) } /// Return the inside-the-polygon sub-segments of `p0`→`p1` as endpoint pairs. /// Handles convex and concave boundaries by testing the midpoint of every /// interval between consecutive boundary crossings. +#[cfg(test)] fn clip_segment( p0: [f32; 3], p1: [f32; 3], poly: &[[f32; 2]], ) -> Vec<([f32; 3], [f32; 3])> { + let lerp = |t: f32| { + [ + p0[0] + (p1[0] - p0[0]) * t, + p0[1] + (p1[1] - p0[1]) * t, + p0[2] + (p1[2] - p0[2]) * t, + ] + }; + clip_segment_params(p0, p1, poly) + .into_iter() + .map(|(a, b)| (lerp(a), lerp(b))) + .collect() +} + +fn clip_segment_params(p0: [f32; 3], p1: [f32; 3], poly: &[[f32; 2]]) -> Vec<(f32, f32)> { let mut ts: Vec = vec![0.0, 1.0]; let n = poly.len(); let mut j = n - 1; @@ -493,7 +575,7 @@ fn clip_segment( ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); ts.dedup_by(|a, b| (*a - *b).abs() < 1e-7); - let lerp = |t: f32| { + let point = |t: f32| { [ p0[0] + (p1[0] - p0[0]) * t, p0[1] + (p1[1] - p0[1]) * t, @@ -502,9 +584,9 @@ fn clip_segment( }; let mut out = Vec::new(); for w in ts.windows(2) { - let mid = lerp(0.5 * (w[0] + w[1])); + let mid = point(0.5 * (w[0] + w[1])); if point_in_poly(mid[0], mid[1], poly) { - out.push((lerp(w[0]), lerp(w[1]))); + out.push((w[0], w[1])); } } out diff --git a/src/scene/pipeline/hatch_gpu/storage.rs b/src/scene/pipeline/hatch_gpu/storage.rs index 43a8edd1..950a9892 100644 --- a/src/scene/pipeline/hatch_gpu/storage.rs +++ b/src/scene/pipeline/hatch_gpu/storage.rs @@ -295,9 +295,7 @@ impl StorageHatchBatch { // along-line phase. let perp_step = fam.dy; let along_step = fam.dx; - // Screen-space derivative drives 1-px line width - // in the shader; this stored field is unused. - let line_width = 0.0_f32; + let line_width = h.line_weight_px.max(1.0); let period: f32 = fam.dashes.iter().map(|d| d.abs()).sum(); families.push(LineFamilyGpu { cos_a: fam.angle_deg.to_radians().cos(), diff --git a/src/scene/pipeline/hatch_gpu/texture.rs b/src/scene/pipeline/hatch_gpu/texture.rs index f0d58b25..c777b3bb 100644 --- a/src/scene/pipeline/hatch_gpu/texture.rs +++ b/src/scene/pipeline/hatch_gpu/texture.rs @@ -286,7 +286,12 @@ impl TextureHatch { texels.push([angle_r.cos(), angle_r.sin(), fam.x0, fam.y0]); texels.push([fam.dx, fam.dy, fam.dy, fam.dx]); // Counts as exact f32 (small integers → no denormal/bitcast risk). - texels.push([0.0, period, n_dashes as f32, dash_rel as f32]); + texels.push([ + model.line_weight_px.max(1.0), + period, + n_dashes as f32, + dash_rel as f32, + ]); n_families += 1; } } diff --git a/src/scene/pipeline/text_gpu.rs b/src/scene/pipeline/text_gpu.rs index 8bfc3bee..14d2b3b5 100644 --- a/src/scene/pipeline/text_gpu.rs +++ b/src/scene/pipeline/text_gpu.rs @@ -151,6 +151,51 @@ pub fn push_glyph_vertices( } } +#[allow(clippy::too_many_arguments)] +pub fn push_glyph_vertices_on_plane( + out: &mut Vec, + 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 diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 7187320d..351889dd 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -406,40 +406,45 @@ fn pack_color(color: [f32; 4]) -> [u8; 4] { /// non-dash-first patterns keep the legacy centred phase. fn wire_distances(wire: &WireModel) -> (Vec, f32, f32) { let n = wire.points.len(); - let mut dists = vec![0.0_f32; n]; - let mut has_break = false; - // Accumulate arc-length in f64 from double-single deltas (high + low). An - // f32-high-only delta `q[0] - p[0]` quantises ~0.1 at UTM coordinates - // (−1.2M), which shifts the dash phase and drifts parallel lines out of sync - // — the reason MLINE elements were CPU-dashed. `points_low` may be empty for - // non-RTE wires → treat the low half as zero (same as the old behaviour). - let mut acc = 0.0_f64; - for i in 1..n { - let p = wire.points[i - 1]; - let q = wire.points[i]; - if !p[0].is_finite() || !q[0].is_finite() { - has_break = true; - // plinegen=false: reset to 0 at the first real point after a NaN separator. - if !wire.plinegen && !p[0].is_finite() && q[0].is_finite() { - acc = 0.0; + let explicit = wire.pattern_stations.len() >= n + 1; + let (mut dists, has_break, total) = if explicit { + ( + wire.pattern_stations[..n].to_vec(), + !wire.plinegen, + wire.pattern_stations[n], + ) + } else { + let mut dists = vec![0.0_f32; n]; + let mut has_break = false; + // Accumulate arc-length in f64 from double-single deltas (high + low). + let mut acc = 0.0_f64; + for i in 1..n { + let p = wire.points[i - 1]; + let q = wire.points[i]; + if !p[0].is_finite() || !q[0].is_finite() { + has_break = true; + if !wire.plinegen && !p[0].is_finite() && q[0].is_finite() { + acc = 0.0; + } + dists[i] = acc as f32; + } else { + let pl = wire.points_low.get(i - 1).copied().unwrap_or([0.0; 3]); + let ql = wire.points_low.get(i).copied().unwrap_or([0.0; 3]); + let dx = (q[0] as f64 - p[0] as f64) + (ql[0] as f64 - pl[0] as f64); + let dy = (q[1] as f64 - p[1] as f64) + (ql[1] as f64 - pl[1] as f64); + let dz = (q[2] as f64 - p[2] as f64) + (ql[2] as f64 - pl[2] as f64); + acc += (dx * dx + dy * dy + dz * dz).sqrt(); + dists[i] = acc as f32; } - dists[i] = acc as f32; - } else { - let pl = wire.points_low.get(i - 1).copied().unwrap_or([0.0; 3]); - let ql = wire.points_low.get(i).copied().unwrap_or([0.0; 3]); - let dx = (q[0] as f64 - p[0] as f64) + (ql[0] as f64 - pl[0] as f64); - let dy = (q[1] as f64 - p[1] as f64) + (ql[1] as f64 - pl[1] as f64); - let dz = (q[2] as f64 - p[2] as f64) + (ql[2] as f64 - pl[2] as f64); - acc += (dx * dx + dy * dy + dz * dz).sqrt(); - dists[i] = acc as f32; } - } + let total = dists.last().copied().unwrap_or(0.0); + (dists, has_break, total) + }; let pat_len = wire.pattern_length; if pat_len <= 1e-6 || has_break || n < 2 { return (dists, 0.0, 0.0); } - let total = dists[n - 1]; if total <= 1e-6 { return (dists, 0.0, 0.0); } diff --git a/src/scene/preview.rs b/src/scene/preview.rs index 5c838dc9..10abfa5a 100644 --- a/src/scene/preview.rs +++ b/src/scene/preview.rs @@ -351,10 +351,8 @@ impl Scene { (plain, texts) } - /// Boundary outline wire for a hatch, reconstructed from its cached - /// `HatchModel` (offsets from `world_origin`). Used only for edit previews — - /// the normal render shows the fill, not this outline. - fn hatch_outline_wire(&self, handle: Handle) -> Option { + /// Boundary wire for edit previews and selected hatches. + pub(super) fn hatch_outline_wire(&self, handle: Handle) -> Option { let m = self.hatches.get(&handle)?; Self::hatch_model_outline_wire(handle, m) } @@ -375,12 +373,15 @@ impl Scene { if pts.len() < 2 { return None; } - Some(WireModel::solid_f64( + let mut wire = WireModel::solid_f64( handle.value().to_string(), pts, m.color, false, - )) + ); + wire.aci = m.aci; + wire.line_weight_px = m.line_weight_px; + Some(wire) } /// Fill-only geometry has no resident wire for the normal rollover xray. diff --git a/src/scene/project.rs b/src/scene/project.rs index 791529b9..786b1163 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -246,8 +246,17 @@ impl Scene { continue; } - let clipped = - clip_polyline_to_rect(&projected_pts, vp_x0, vp_y0, vp_x1, vp_y1, pcz); + let stationed = wire.pattern_stations.len() > wire.points.len(); + let source_length = stationed.then(|| wire.pattern_stations[wire.points.len()]); + let (clipped, mut clipped_stations) = clip_polyline_to_rect( + &projected_pts, + stationed.then_some(&wire.pattern_stations[..wire.points.len()]), + vp_x0, + vp_y0, + vp_x1, + vp_y1, + pcz, + ); if clipped.is_empty() && projected_text.is_empty() { continue; } @@ -312,6 +321,15 @@ impl Scene { } let mut out = wire.clone(); out.points = clipped; + if let Some(source_length) = source_length { + for station in &mut clipped_stations { + *station *= scale; + } + clipped_stations.push(source_length * scale); + out.pattern_stations = clipped_stations; + } else { + out.pattern_stations.clear(); + } out.text_verts = projected_text; // Paper coordinates are small sheet units — no relative-to-eye // residual is needed, and keeping the model wire's points_low @@ -980,14 +998,16 @@ fn cs_clip( /// Returns a new points vec with proper NaN separators at clip boundaries. fn clip_polyline_to_rect( pts: &[[f32; 3]], + stations: Option<&[f32]>, xmin: f32, ymin: f32, xmax: f32, ymax: f32, z: f32, -) -> Vec<[f32; 3]> { +) -> (Vec<[f32; 3]>, Vec) { const NAN3: [f32; 3] = [f32::NAN, f32::NAN, f32::NAN]; let mut result: Vec<[f32; 3]> = Vec::new(); + let mut result_stations = Vec::new(); let mut i = 0; while i < pts.len() { @@ -1016,19 +1036,50 @@ fn clip_polyline_to_rect( pen_down = false; } Some((cx0, cy0, cx1, cy1)) => { + let parameter = |x: f32, y: f32| { + let dx = x1 - x0; + let dy = y1 - y0; + if dx.abs() >= dy.abs() && dx.abs() > 1e-12 { + (x - x0) / dx + } else if dy.abs() > 1e-12 { + (y - y0) / dy + } else { + 0.0 + } + }; + let station = |t: f32| { + stations.map_or(0.0, |values| { + let start_station = values[start + j]; + start_station + + (values[start + j + 1] - start_station) * t + }) + }; if !pen_down { if !result.is_empty() { result.push(NAN3); + if stations.is_some() { + result_stations.push(0.0); + } } result.push([cx0, cy0, z]); + if stations.is_some() { + result_stations.push(station(parameter(cx0, cy0))); + } pen_down = true; } else if let Some(&[lx, ly, _]) = result.last() { if (lx - cx0).abs() > 1e-4 || (ly - cy0).abs() > 1e-4 { result.push(NAN3); result.push([cx0, cy0, z]); + if stations.is_some() { + result_stations.push(0.0); + result_stations.push(station(parameter(cx0, cy0))); + } } } result.push([cx1, cy1, z]); + if stations.is_some() { + result_stations.push(station(parameter(cx1, cy1))); + } // If the exit point was clipped, lift pen. if (cx1 - x1).abs() > 1e-4 || (cy1 - y1).abs() > 1e-4 { pen_down = false; @@ -1044,6 +1095,9 @@ fn clip_polyline_to_rect( .unwrap_or(false) { result.pop(); + if stations.is_some() { + result_stations.pop(); + } } - result + (result, result_stations) } diff --git a/src/scene/selection.rs b/src/scene/selection.rs index b58d85e2..aa951bdd 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -1,6 +1,9 @@ // Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged. use super::*; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + impl Scene { // ── Selection ───────────────────────────────────────────────────────── /// Treat a classic LEADER and its attached annotation as one logical object. @@ -37,8 +40,11 @@ impl Scene { } pub fn select_entity(&mut self, handle: Handle, exclusive: bool) { let handles = self.handles_expanded_for_leader_annotations(&[handle]); + let mut changed = false; if exclusive { + changed = self.selected.len() != handles.len() + || handles.iter().any(|handle| !self.selected.contains(handle)); self.selected.clear(); self.selected_order.clear(); } @@ -46,15 +52,35 @@ impl Scene { for handle in handles { if self.selected.insert(handle) { self.selected_order.push(handle); + changed = true; } } - self.bump_selection(); + if changed { + self.bump_selection_set(); + } } pub fn deselect_all(&mut self) { + if self.selected.is_empty() { + return; + } self.selected.clear(); self.selected_order.clear(); - self.bump_selection(); + self.bump_selection_set(); + } + + pub(crate) fn selection_fingerprint(&mut self) -> u64 { + if self.selection_fingerprint_dirty { + let mut fingerprint = self.selected.len() as u64; + for handle in &self.selected { + let mut hasher = DefaultHasher::new(); + handle.hash(&mut hasher); + fingerprint ^= hasher.finish(); + } + self.selection_fingerprint_cache = fingerprint; + self.selection_fingerprint_dirty = false; + } + self.selection_fingerprint_cache } pub(crate) fn selected_handles_in_order(&self) -> Vec { @@ -103,7 +129,7 @@ impl Scene { order.extend(added); self.selected = selected; self.selected_order = order; - self.bump_selection(); + self.bump_selection_set(); } } @@ -118,7 +144,7 @@ impl Scene { } if changed { - self.bump_selection(); + self.bump_selection_set(); } } @@ -199,7 +225,7 @@ impl Scene { } } if added > 0 { - self.bump_selection(); + self.bump_selection_set(); } added } @@ -223,7 +249,9 @@ impl Scene { self.selected_order.push(h); } } - self.bump_selection(); + if self.selected != prev { + self.bump_selection_set(); + } self.selected.len() } @@ -463,6 +491,7 @@ impl Scene { match prop.value { PropValue::PlainText(_) => QSelectValueEditor::Text, PropValue::ReadOnly(ref value) + | PropValue::ReadOnlyWithTooltip { ref value, .. } | PropValue::EditText(ref value) => { let field = prop.field.to_ascii_lowercase(); let textual = [ @@ -660,6 +689,7 @@ impl Scene { .find(|p| p.field == field)?; Some(match prop.value { PropValue::ReadOnly(s) + | PropValue::ReadOnlyWithTooltip { value: s, .. } | PropValue::EditText(s) | PropValue::PlainText(s) => s, PropValue::LayerChoice(s) => s, @@ -707,7 +737,8 @@ impl Scene { let mut handle_set: HashSet = HashSet::default(); let mut erased: Vec<(Handle, ChangeKind)> = Vec::new(); - let mut highlight_changed = false; + let mut selection_changed = false; + let mut hover_changed = false; for &h in &erase_handles { // Objects on a locked layer can't be erased. @@ -722,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); @@ -736,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 @@ -836,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); + } +} diff --git a/src/scene/text/complex_lt.rs b/src/scene/text/complex_lt.rs index dfad53c7..1cb8c710 100644 --- a/src/scene/text/complex_lt.rs +++ b/src/scene/text/complex_lt.rs @@ -311,6 +311,7 @@ pub fn apply_along( .map(|pts| WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, @@ -358,6 +359,7 @@ pub fn apply_along( out.push(WireModel { point_marker: None, taper_widths: Vec::new(), + pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index b96a6941..48de4322 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -61,7 +61,7 @@ pub struct Camera { /// that span, clipping the drawing (#473). Re-projecting the box each frame /// keeps near/far exactly as deep as the current view needs. `None` falls /// back to `depth_half_range`. - pub model_bounds: Option<(Vec3, Vec3)>, + pub model_bounds: Option<(DVec3, DVec3)>, } impl Default for Camera { @@ -435,25 +435,24 @@ impl Camera { if let Some((min, max)) = self.model_bounds { let corners = [ - Vec3::new(min.x, min.y, min.z), - Vec3::new(max.x, min.y, min.z), - Vec3::new(min.x, max.y, min.z), - Vec3::new(max.x, max.y, min.z), - Vec3::new(min.x, min.y, max.z), - Vec3::new(max.x, min.y, max.z), - Vec3::new(min.x, max.y, max.z), - Vec3::new(max.x, max.y, max.z), + DVec3::new(min.x, min.y, min.z), + DVec3::new(max.x, min.y, min.z), + DVec3::new(min.x, max.y, min.z), + DVec3::new(max.x, max.y, min.z), + DVec3::new(min.x, min.y, max.z), + DVec3::new(max.x, min.y, max.z), + DVec3::new(min.x, max.y, max.z), + DVec3::new(max.x, max.y, max.z), ]; - let mut new_min = Vec3::splat(f32::INFINITY); - let mut new_max = Vec3::splat(f32::NEG_INFINITY); + let mut new_min = DVec3::splat(f64::INFINITY); + let mut new_max = DVec3::splat(f64::NEG_INFINITY); for corner in corners { let transformed = transform.apply(acadrust::types::Vector3::new( - corner.x as f64, - corner.y as f64, - corner.z as f64, + corner.x, + corner.y, + corner.z, )); - let transformed = - Vec3::new(transformed.x as f32, transformed.y as f32, transformed.z as f32); + let transformed = DVec3::new(transformed.x, transformed.y, transformed.z); new_min = new_min.min(transformed); new_max = new_max.max(transformed); } @@ -461,11 +460,7 @@ impl Camera { } } - /// The camera's roll — rotation about the view axis, in radians — the - /// inverse of the `roll` argument to [`yaw_pitch_to_quat`]. Recovered by - /// removing the yaw/pitch frame from the live rotation, so a saved view can - /// store its twist and round-trip it. Exact for a plan view (the twisted- - /// UCS case); approximate after a free 3D orbit. + /// The camera's rotation about its view axis. pub fn roll(&self) -> f32 { let q_yp = yaw_pitch_to_quat(self.yaw, self.pitch, 0.0); let q_roll = q_yp.conjugate() * self.rotation; @@ -582,16 +577,16 @@ impl Camera { /// current target: `x`/`y` span the screen plane, `z` runs along the eye /// direction. The offset is taken in f64 before the cast, so a corner at /// UTM scale doesn't lose the difference to cancellation. - fn bounds_in_view(&self, min: Vec3, max: Vec3) -> [Vec3; 8] { + fn bounds_in_view(&self, min: DVec3, max: DVec3) -> [Vec3; 8] { let inv = self.rotation.inverse(); let mut out = [Vec3::ZERO; 8]; for (i, slot) in out.iter_mut().enumerate() { - let corner = Vec3::new( + let corner = DVec3::new( if i & 1 == 0 { min.x } else { max.x }, if i & 2 == 0 { min.y } else { max.y }, if i & 4 == 0 { min.z } else { max.z }, ); - *slot = inv * (corner.as_dvec3() - self.target).as_vec3(); + *slot = inv * (corner - self.target).as_vec3(); } out } @@ -599,7 +594,7 @@ impl Camera { /// Half-extent of `min..max` along the current eye direction (with the /// same 5% margin `ortho_depth_range` needs), measured from the target. /// This is exactly what `distance ± r` has to contain to avoid clipping. - fn depth_extent_in_view(&self, min: Vec3, max: Vec3) -> f32 { + fn depth_extent_in_view(&self, min: DVec3, max: DVec3) -> f32 { let depth_r = self .bounds_in_view(min, max) .iter() @@ -617,7 +612,11 @@ impl Camera { /// 800 km below its plane zoomed out to 800 km and became a dot. The two /// agree on a flat drawing, which is why it went unnoticed. pub fn fit_to_bounds(&mut self, min: Vec3, max: Vec3, aspect: f32) { - self.target = ((min + max) * 0.5).as_dvec3(); + self.fit_to_bounds_f64(min.as_dvec3(), max.as_dvec3(), aspect); + } + + pub fn fit_to_bounds_f64(&mut self, min: DVec3, max: DVec3, aspect: f32) { + self.target = (min + max) * 0.5; let corners = self.bounds_in_view(min, max); // Half-height the view needs so the box fits BOTH axes at the given // viewport aspect. The old circumscribed-radius rule fitted the box's @@ -632,7 +631,7 @@ impl Camera { // that to get the distance which just contains `half_h`, plus a small // border margin. self.distance = (half_h / (self.fov_y * 0.5).tan() * 1.1).max(1e-3); - self.fit_depth_to_bounds(min, max); + self.fit_depth_to_bounds_f64(min, max); } /// Size only the near/far span to `min..max`, leaving the pose alone. @@ -640,7 +639,7 @@ impl Camera { /// Split out of [`fit_to_bounds`] for the camera restored from a file's /// saved view: that pose must not move, but its depth range still has to /// cover the model or geometry outside it is silently clipped away. - pub fn fit_depth_to_bounds(&mut self, min: Vec3, max: Vec3) { + pub fn fit_depth_to_bounds_f64(&mut self, min: DVec3, max: DVec3) { // Cache the box so `ortho_depth_range` re-derives the near/far depth for // the live eye direction every frame — orbiting off the fitted pose then // never clips the drawing (#473). Keeping it tied to the model (not to @@ -652,7 +651,7 @@ impl Camera { self.depth_half_range = self.depth_extent_in_view(min, max); } - pub(crate) fn fitted_model_bounds(&self) -> Option<(Vec3, Vec3)> { + pub(crate) fn fitted_model_bounds(&self) -> Option<(DVec3, DVec3)> { self.model_bounds } @@ -767,16 +766,15 @@ impl Camera { // ── Internal helpers ─────────────────────────────────────────────────── - /// Derive yaw and pitch from the current quaternion for the ViewCube - /// hit-test functions (hit_test / hover_id). These use yaw/pitch to - /// compute the same rotation matrix as the shader, so they must match. + /// Derive yaw and pitch from the current quaternion. fn sync_yaw_pitch(&mut self) { - // Eye direction in world space (canonical eye dir is +Z). let eye_dir = self.rotation * Vec3::Z; - // pitch: angle above/below the XY plane. - self.pitch = eye_dir.z.clamp(-0.999, 0.999).asin(); - // yaw: atan2(x, y) matches from_rotation_z(yaw) used in view_rotation_mat. - self.yaw = eye_dir.x.atan2(eye_dir.y); + self.pitch = eye_dir.z.clamp(-1.0, 1.0).asin(); + self.yaw = if eye_dir.x.abs() < 1e-6 && eye_dir.y.abs() < 1e-6 { + 0.0 + } else { + eye_dir.x.atan2(-eye_dir.y) + }; } } @@ -787,7 +785,7 @@ impl Camera { /// so snap angles continue to work unchanged. /// /// Convention (Z-up, Y-forward): -/// yaw = 0 → camera looks along +Y axis (front view) +/// yaw = 0 → camera looks along -Y axis (front view) /// pitch = PI/2 → camera looks down -Z (top view) /// pitch = 0 → camera in the XY plane /// Build a rotation quaternion from yaw, pitch and roll. @@ -795,7 +793,7 @@ impl Camera { /// Roll rotates the camera around its own view axis (post-multiplied so it /// composes after the yaw/pitch gaze direction is set). pub fn yaw_pitch_to_quat(yaw: f32, pitch: f32, roll: f32) -> Quat { - // +yaw so ViewCube faces match camera direction (FRONT at yaw=0 = +Y world axis). + // +yaw keeps the ViewCube faces aligned with the camera direction. let q_yaw = Quat::from_rotation_z(yaw); let q_pitch = Quat::from_rotation_x(std::f32::consts::FRAC_PI_2 - pitch); let q_roll = Quat::from_rotation_z(roll); diff --git a/src/scene/view/dispatch.rs b/src/scene/view/dispatch.rs index dfeaccbf..34c44132 100644 --- a/src/scene/view/dispatch.rs +++ b/src/scene/view/dispatch.rs @@ -135,11 +135,55 @@ pub fn apply_common_prop(entity: &mut EntityType, field: &str, value: &str) { } } "transparency" => { - if let Ok(pct) = value.trim().parse::() { - let alpha = (pct.clamp(0.0, 100.0) / 100.0 * 255.0).round() as u8; - entity - .as_entity_mut() - .set_transparency(Transparency::new(alpha)); + let s = value.trim(); + if s.eq_ignore_ascii_case("ByLayer") { + entity.as_entity_mut().set_transparency(Transparency::new(0)); + } else if s.eq_ignore_ascii_case("ByBlock") { + entity.as_entity_mut().set_transparency(Transparency::new(1)); + } else { + let clean = s.trim_end_matches('%').trim(); + if let Ok(pct) = clean.parse::() { + let alpha = (pct.clamp(0.0, 90.0) / 100.0 * 255.0).round() as u8; + entity + .as_entity_mut() + .set_transparency(Transparency::new(alpha)); + } + } + } + "material" => { + let common = entity.common_mut(); + match value.trim() { + "ByLayer" => { + common.material_flags = 0; + common.material_handle = None; + } + "ByBlock" => { + common.material_flags = 1; + common.material_handle = None; + } + "Global" => { + common.material_flags = 2; + common.material_handle = None; + } + _ => {} + } + } + "plot_style" => { + let common = entity.common_mut(); + match value.trim() { + "ByLayer" => { + common.plotstyle_flags = 0; + common.plotstyle_handle = None; + } + "ByBlock" => { + common.plotstyle_flags = 1; + common.plotstyle_handle = None; + } + "Normal" => { + common.plotstyle_flags = 2; + common.plotstyle_handle = None; + } + _ => {} } } "thickness" => { diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index cb96d6ea..daaa3f5a 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -22,6 +22,8 @@ use crate::scene::{ SceneLight, Uniforms, ViewportInstance, WireModel, }; +const DISPLAY_STYLE_CACHE_LIMIT: usize = if cfg!(target_arch = "wasm32") { 4 } else { 24 }; + // ── Camera hover state (shader::Program::State) ─────────────────────────── #[derive(Clone, Default)] @@ -2496,6 +2498,238 @@ impl Scene { }; (final_color, pl, pat, lw, aci) } + + fn display_plot_style(&self) -> Option> { + if self.current_layout == "Model" { + return None; + } + let settings = self.effective_plot_settings()?; + if !settings.flags.show_plot_styles || settings.current_style_sheet.is_empty() { + return None; + } + let key = ( + self.current_layout.clone(), + settings.current_style_sheet.to_ascii_lowercase(), + ); + if let Some(style) = self.display_plot_style_cache.borrow().get(&key) { + return style.clone(); + } + let style = crate::io::plot_style::PlotStyleTable::load_named( + &settings.current_style_sheet, + ) + .ok() + .map(Arc::new); + self.display_plot_style_cache + .borrow_mut() + .insert(key, style.clone()); + style + } + + fn apply_display_plot_style( + &self, + color: &mut [f32; 4], + aci: u8, + style: &crate::io::plot_style::PlotStyleTable, + ) { + if aci == 0 { + return; + } + if let Some(rgb) = style.resolve_color(aci) { + color[..3].copy_from_slice(&rgb); + } + let screening = style.resolve_screening(aci); + for (channel, paper) in color[..3] + .iter_mut() + .zip(self.paper_bg_color[..3].iter()) + { + *channel = *channel * screening + *paper * (1.0 - screening); + } + } + + fn display_styled_wires( + &self, + source: Arc>, + source_gen: u64, + ) -> (Arc>, u64) { + let Some(style) = self.display_plot_style() else { + return (source, source_gen); + }; + let key = (source_gen, style.name.to_ascii_lowercase()); + if let Some((gen, wires)) = self.styled_wire_cache.borrow().get(&key) { + return (Arc::clone(wires), *gen); + } + let mut wires = source.as_ref().clone(); + for wire in &mut wires { + self.apply_display_plot_style(&mut wire.color, wire.aci, &style); + if wire.aci > 0 { + if wire.fill_is_2d_solid + && style + .aci_entries + .get(wire.aci as usize) + .is_some_and(|entry| (65..=72).contains(&entry.fill_style)) + { + wire.fill_tris.clear(); + wire.fill_tris_low.clear(); + } + if let Some(mm) = style.resolve_lineweight(wire.aci) { + wire.line_weight_px = (mm * (96.0 / 25.4) * 2.0).max(1.0); + } + for vertex in &mut wire.text_verts { + self.apply_display_plot_style(&mut vertex.color, wire.aci, &style); + } + } + } + let wires = Arc::new(wires); + let gen = crate::scene::WIRE_CONTENT_GEN + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut cache = self.styled_wire_cache.borrow_mut(); + if cache.len() >= DISPLAY_STYLE_CACHE_LIMIT { + cache.clear(); + } + cache.insert(key, (gen, Arc::clone(&wires))); + (wires, gen) + } + + fn display_styled_hatches( + &self, + source: Arc>, + wire_fills: Option<&Arc>>, + pattern_scale: f32, + ) -> Arc> { + let Some(style) = self.display_plot_style() else { + return source; + }; + let key = ( + self.geometry_epoch, + Arc::as_ptr(&source) as usize, + wire_fills.map_or(0, |fills| Arc::as_ptr(fills) as usize), + style.name.to_ascii_lowercase(), + pattern_scale.to_bits(), + ); + if let Some(hatches) = self.styled_hatch_cache.borrow().get(&key) { + return Arc::clone(hatches); + } + let mut hatches = source.as_ref().clone(); + for hatch in &mut hatches { + self.apply_display_plot_style(&mut hatch.color, hatch.aci, &style); + if hatch.aci > 0 { + if matches!(hatch.pattern, crate::scene::model::hatch_model::HatchPattern::Solid) { + if let Some(fill_style) = style + .aci_entries + .get(hatch.aci as usize) + .and_then(|entry| { + crate::scene::model::hatch_model::plot_style_fill_pattern( + entry.fill_style, + ) + }) + { + hatch.pattern = fill_style; + hatch.scale = pattern_scale; + } + } + if let Some(mm) = style.resolve_lineweight(hatch.aci) { + hatch.line_weight_px = (mm * (96.0 / 25.4) * 2.0).max(1.0); + } + if let crate::scene::model::hatch_model::HatchPattern::Gradient { + color2, .. + } = &mut hatch.pattern + { + self.apply_display_plot_style(color2, hatch.aci, &style); + } + } + } + if let Some(wire_fills) = wire_fills { + hatches.extend(wire_fills.iter().cloned()); + } + let hatches = Arc::new(hatches); + let mut cache = self.styled_hatch_cache.borrow_mut(); + cache.retain(|(epoch, _, _, _, _), _| *epoch == self.geometry_epoch); + if cache.len() >= DISPLAY_STYLE_CACHE_LIMIT * 2 { + cache.clear(); + } + cache.insert(key, Arc::clone(&hatches)); + hatches + } + + fn display_styled_wire_fills( + &self, + wires: &Arc>, + source_gen: u64, + pattern_scale: f32, + ) -> Option>> { + let Some(style) = self.display_plot_style() else { + return None; + }; + let key = ( + source_gen, + style.name.to_ascii_lowercase(), + pattern_scale.to_bits(), + ); + if let Some(hatches) = self.styled_wire_fill_cache.borrow().get(&key) { + return Some(Arc::clone(hatches)); + } + let mut hatches = Vec::new(); + for wire in wires.iter().filter(|wire| wire.fill_is_2d_solid && wire.aci > 0) { + let Some(pattern) = style + .aci_entries + .get(wire.aci as usize) + .and_then(|entry| { + (65..=72) + .contains(&entry.fill_style) + .then(|| { + crate::scene::model::hatch_model::plot_style_fill_pattern( + entry.fill_style, + ) + }) + .flatten() + }) + else { + continue; + }; + let mut color = wire.color; + self.apply_display_plot_style(&mut color, wire.aci, &style); + let line_weight_px = style + .resolve_lineweight(wire.aci) + .map(|mm| (mm * (96.0 / 25.4) * 2.0).max(1.0)) + .unwrap_or(wire.line_weight_px); + for (triangle_index, triangle) in wire.fill_tris.chunks_exact(3).enumerate() { + let mut boundary = Vec::with_capacity(4); + for (point_index, point) in triangle.iter().enumerate() { + let index = triangle_index * 3 + point_index; + let low = wire.fill_tris_low.get(index).copied().unwrap_or([0.0; 3]); + boundary.push([point[0] + low[0], point[1] + low[1]]); + } + boundary.push(boundary[0]); + hatches.push(HatchModel { + render_instance: wire.render_instance.clone(), + world_origin: [0.0, 0.0], + boundary: Arc::new(boundary), + boundary_wcs: None, + fill_plane: None, + fill_plane_boundary: None, + boundary_exterior: None, + boundary_sources: None, + boundary_paths: None, + style: acadrust::entities::HatchStyleType::Normal, + pattern: pattern.clone(), + name: "PLOTSTYLE".to_string(), + color, + aci: 0, + line_weight_px, + angle_offset: 0.0, + scale: pattern_scale, + draw_depth: wire.depth_override.unwrap_or(0.0), + }); + } + } + let hatches = Arc::new(hatches); + let mut cache = self.styled_wire_fill_cache.borrow_mut(); + if cache.len() >= DISPLAY_STYLE_CACHE_LIMIT { + cache.clear(); + } + cache.insert(key, Arc::clone(&hatches)); + Some(hatches) + } } /// Whether an entity sits on a locked layer (via the document's layer table). @@ -3076,15 +3310,38 @@ impl Scene { let Some(entity) = self.document.get_entity(handle) else { continue; }; - if !crate::scene::annotative::is_annotative(&self.document, entity) - || !self.resident_entity_visible( + if !self.resident_entity_visible( entity, target_block, Some(&frozen), annotation_scale_handle, true, - ) - { + ) { + continue; + } + + if matches!(entity, EntityType::Hatch(_)) { + if selected { + if let Some(mut wire) = self.hatch_outline_wire(handle) { + let (_, pattern_length, pattern, line_weight_px, aci) = + render_style_for_viewport( + &self.document, + entity, + content_viewport.then_some(inst.handle), + ); + wire.color = WireModel::SELECTED; + wire.selected = true; + wire.pattern_length = pattern_length; + wire.pattern = pattern; + wire.line_weight_px = line_weight_px; + wire.aci = aci; + wires.push(wire); + } + } + continue; + } + + if !crate::scene::annotative::is_annotative(&self.document, entity) { continue; } @@ -3174,6 +3431,7 @@ impl Scene { model_render_mode: acadrust::entities::ViewportRenderMode, _hover_region: Option, show_viewcube: bool, + show_interaction: bool, viewcube_text_color: [f32; 4], ) -> Primitive { let nav_build_started = iced::time::Instant::now(); @@ -3181,7 +3439,7 @@ impl Scene { // Hover comes from the scene cell driven by the app-level // `CursorMoved` handler — the cube overlay sits above the shader // and would otherwise mask the move event from `Program::update`. - let hover_region = self.viewcube_hover.get(); + let hover_region = show_interaction.then(|| self.viewcube_hover.get()).flatten(); self.selection.borrow_mut().vp_size = (bounds.width, bounds.height); if bounds.height > 0.0 { self.set_render_aspect(bounds.width / bounds.height); @@ -3197,7 +3455,14 @@ impl Scene { .iter() .filter_map(|inst| { let force = self.refresh_consume(self.instance_id_for(inst)); - self.viewport_data_for(inst, canvas, hover_region, show_viewcube, force) + self.viewport_data_for( + inst, + canvas, + hover_region, + show_viewcube, + show_interaction, + force, + ) }) .collect(); // Empty viewports → blit nothing; the container background (model bg @@ -3226,9 +3491,10 @@ impl Scene { tile_idx: usize, model_render_mode: acadrust::entities::ViewportRenderMode, show_viewcube: bool, + show_interaction: bool, viewcube_text_color: [f32; 4], ) -> Primitive { - let hover_region = self.viewcube_hover.get(); + let hover_region = show_interaction.then(|| self.viewcube_hover.get()).flatten(); let canvas = (bounds.width.max(1.0), bounds.height.max(1.0)); let bg_color = [0.0, 0.0, 0.0, 0.0]; let tiles = self.model_tiles.borrow(); @@ -3279,7 +3545,14 @@ impl Scene { }; let force = self.refresh_consume(self.instance_id_for(&inst)); let viewports = self - .viewport_data_for(&inst, canvas, hover_region, show_viewcube, force) + .viewport_data_for( + &inst, + canvas, + hover_region, + show_viewcube, + show_interaction, + force, + ) .into_iter() .collect(); let perf_nav = perf_nav.map(|mut sample| { @@ -3304,6 +3577,7 @@ impl Scene { canvas: (f32, f32), hover_region: Option, show_viewcube: bool, + show_interaction: bool, force_rasterize: bool, ) -> Option { let display = self.viewport_display_settings(inst); @@ -3363,11 +3637,15 @@ impl Scene { } else { self.model_wires_for_viewport_arc(inst.handle, full.height) }; + let source_gen = self.last_model_wire_gen.get(); + let styled_fill_source = Arc::clone(&base_arc); + let (base_arc, styled_gen) = self.display_styled_wires(base_arc, source_gen); + self.last_model_wire_gen.set(styled_gen); // Wire-buffer content id for the upload gate. Preview / interim wires // are NOT part of this buffer anymore (they go in a separate per-frame // overlay buffer below), so the base id is the source's stable content // gen — a drag or camera move never re-uploads the base wire set. - let base_wire_content_id = self.last_model_wire_gen.get(); + let base_wire_content_id = styled_gen; let base_wire_patch = self.model_wire_patch_for(base_wire_content_id); // Split Face3D wires from the rest. The split is content-only (keyed // by the wire-set content id), so while the geometry is unchanged it's @@ -3443,7 +3721,7 @@ impl Scene { // paper layout, model-space overlays go to all content viewports while // paper-space overlays stay on the sheet. This also keeps model-space // coordinates out of the full-canvas sheet pass (#540). - let show_live_overlay = if self.current_layout == "Model" { + let show_live_overlay = show_interaction && if self.current_layout == "Model" { true } else if self.active_viewport.is_some() { !inst.paper_sheet @@ -3508,8 +3786,15 @@ impl Scene { width: full.width.max(1.0), height: full.height.max(1.0), }; - let mut uniforms = - Uniforms::new(&inst.camera, full_bounds, self.document.header.lineweight_display); + let display_plot_lineweights = self.current_layout != "Model" + && self.effective_plot_settings().is_some_and(|settings| { + settings.flags.show_plot_styles && settings.flags.print_lineweights + }); + let mut uniforms = Uniforms::new( + &inst.camera, + full_bounds, + self.document.header.lineweight_display || display_plot_lineweights, + ); if self.document.header.paper_space_linetype_scaling && !inst.paper_sheet && inst.tile_idx.is_none() @@ -3592,15 +3877,39 @@ impl Scene { // paper area. Content viewport model builders are block-filtered too; // the scissor only clips their already-correct Model Space set. let (hatches, wipeout_hatches, paper_images) = if inst.paper_sheet { - let (hatches, wipeouts, images) = self.paper_sheet_render_models(); + let (hatches, wipeouts, images) = + self.paper_sheet_render_models_for_view(show_interaction); (hatches, wipeouts, Some(images)) } else { ( - self.hatch_models_for_viewport(inst.handle, &vp_frozen), + self.hatch_models_for_viewport(inst.handle, &vp_frozen, show_interaction), self.wipeout_models_for_viewport(inst.handle, &vp_frozen), None, ) }; + let viewport_scale = if inst.paper_sheet { + 1.0 + } else { + self.document + .get_entity(inst.handle) + .and_then(|entity| match entity { + EntityType::Viewport(viewport) => Some(vp_effective_scale( + viewport.custom_scale, + viewport.view_height, + viewport.height, + )), + _ => None, + }) + .unwrap_or(1.0) + }; + let pattern_scale = (self.paper_space_unit_factor() / viewport_scale.max(1.0e-9)) as f32; + let styled_wire_fills = + self.display_styled_wire_fills(&styled_fill_source, source_gen, pattern_scale); + let hatches = self.display_styled_hatches( + hatches, + styled_wire_fills.as_ref().filter(|fills| !fills.is_empty()), + pattern_scale, + ); let images = if let Some(images) = paper_images { images } else { @@ -3750,10 +4059,21 @@ impl Scene { camera_generation: self.camera_generation, wire_content_id, wire_patch, - selected_handles: Arc::new(self.selected.iter().copied().collect()), - hover_handles: Arc::new(self.hover_highlight_handles()), - selection_generation: self.selection_generation, - selected_sig: self.selected_set_sig(), + selected_handles: Arc::new(if show_interaction { + self.selected.iter().copied().collect() + } else { + rustc_hash::FxHashSet::default() + }), + hover_handles: Arc::new(if show_interaction { + self.hover_highlight_handles() + } else { + rustc_hash::FxHashSet::default() + }), + selection_generation: self + .selection_generation + .wrapping_mul(2) + .wrapping_add(u64::from(!show_interaction)), + selected_sig: if show_interaction { self.selected_set_sig() } else { 0 }, screen_rect, }) } diff --git a/src/scene/view/viewport_pane.rs b/src/scene/view/viewport_pane.rs index a87a8959..55aa42e8 100644 --- a/src/scene/view/viewport_pane.rs +++ b/src/scene/view/viewport_pane.rs @@ -16,6 +16,7 @@ use iced::{mouse, Event, Rectangle}; pub struct ViewportPane<'a> { pub scene: &'a Scene, pub show_viewcube: bool, + pub show_interaction: bool, /// Render mode applied to the active Model tile or active paper viewport. /// The app may supply a temporary gallery-hover mode; inactive viewports /// keep the values stored on their own tile/entity. @@ -33,12 +34,14 @@ impl<'a> ViewportPane<'a> { pub fn model( scene: &'a Scene, show_viewcube: bool, + show_interaction: bool, render_mode: acadrust::entities::ViewportRenderMode, viewcube_text_color: [f32; 4], ) -> Self { Self { scene, show_viewcube, + show_interaction, render_mode, pane: None, viewcube_text_color, @@ -50,6 +53,7 @@ impl<'a> ViewportPane<'a> { pub fn for_pane( scene: &'a Scene, show_viewcube: bool, + show_interaction: bool, render_mode: acadrust::entities::ViewportRenderMode, tile_idx: usize, viewcube_text_color: [f32; 4], @@ -57,6 +61,7 @@ impl<'a> ViewportPane<'a> { Self { scene, show_viewcube, + show_interaction, render_mode, pane: Some(tile_idx), viewcube_text_color, @@ -82,6 +87,7 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for ViewportPane<'a> idx, self.render_mode, self.show_viewcube, + self.show_interaction, self.viewcube_text_color, ), None => self.scene.build_viewports( @@ -89,6 +95,7 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for ViewportPane<'a> self.render_mode, state.hover_region, self.show_viewcube, + self.show_interaction, self.viewcube_text_color, ), } diff --git a/src/shaders/hatch.wgsl b/src/shaders/hatch.wgsl index f288faee..33c6696b 100644 --- a/src/shaders/hatch.wgsl +++ b/src/shaders/hatch.wgsl @@ -151,7 +151,6 @@ fn check_family( let pz = xz.y - oz; let perp_step = fam.perp_step * scale; - let line_w = abs(fam.line_width * scale); let perp = -px * sin_a + pz * cos_a; let k = round(perp / perp_step); @@ -163,6 +162,8 @@ fn check_family( -ddx_xz.x * sin_a + ddx_xz.y * cos_a, -ddy_xz.x * sin_a + ddy_xz.y * cos_a, )) * 0.5; + let width_px = select(1.0, max(fam.line_width, 1.0), u.lwdisplay_enable > 0.5); + let half_line = half_px * width_px; // World units per screen pixel on each axis — used to light exactly the // one pixel that contains a dot's centre (pixel-snapped, so the dot stays @@ -172,8 +173,8 @@ fn check_family( // A fragment within ~1px of a line may be a dot; everything further out is // empty fill. (A dot's pixel sits on a line, so its perp offset is < 1px.) - if d > half_px * 2.0 { return false; } - if fam.n_dashes == 0u { return d <= half_px; } + if d > max(half_line, half_px * 2.0) { return false; } + if fam.n_dashes == 0u { return d <= half_line; } let along_step = fam.along_step * scale; let period = fam.period * scale; @@ -185,7 +186,7 @@ fn check_family( for (var j = 0u; j < fam.n_dashes; j++) { let sv = dashes[fam.dash_offset + j] * scale; if sv > 0.0 { - if d <= half_px && t_mod >= pos && t_mod < pos + sv { return true; } + if d <= half_line && t_mod >= pos && t_mod < pos + sv { return true; } pos = pos + sv; } else if sv < 0.0 { pos = pos - sv; @@ -197,7 +198,8 @@ fn check_family( let dtv = (t - pos) - round((t - pos) / period) * period; let owx = -dtv * cos_a + dperp * sin_a; let owy = -dtv * sin_a - dperp * cos_a; - if abs(owx / wpx) <= 0.5 && abs(owy / wpy) <= 0.5 { return true; } + let dot_half = width_px * 0.5; + if abs(owx / wpx) <= dot_half && abs(owy / wpy) <= dot_half { return true; } } } return false; diff --git a/src/shaders/hatch_texture.wgsl b/src/shaders/hatch_texture.wgsl index c67df0b8..de40284b 100644 --- a/src/shaders/hatch_texture.wgsl +++ b/src/shaders/hatch_texture.wgsl @@ -146,13 +146,15 @@ fn check_family( -ddx_xz.x * sin_a + ddx_xz.y * cos_a, -ddy_xz.x * sin_a + ddy_xz.y * cos_a, )) * 0.5; + let width_px = select(1.0, max(fam.line_width, 1.0), u.lwdisplay_enable > 0.5); + let half_line = half_px * width_px; let wpx = length(vec2(ddx_xz.x, ddy_xz.x)); let wpy = length(vec2(ddx_xz.y, ddy_xz.y)); - if d > half_px * 2.0 { return false; } + if d > max(half_line, half_px * 2.0) { return false; } - if fam.n_dashes == 0u { return d <= half_px; } + if fam.n_dashes == 0u { return d <= half_line; } let along_step = fam.along_step * scale; let period = fam.period * scale; @@ -165,7 +167,7 @@ fn check_family( let idx = fam.dash_off + j; let sv = texel(h.dash_off + idx / 4u)[idx % 4u] * scale; if sv > 0.0 { - if d <= half_px && t_mod >= pos && t_mod < pos + sv { return true; } + if d <= half_line && t_mod >= pos && t_mod < pos + sv { return true; } pos = pos + sv; } else if sv < 0.0 { pos = pos - sv; @@ -173,7 +175,8 @@ fn check_family( let dtv = (t - pos) - round((t - pos) / period) * period; let owx = -dtv * cos_a + dperp * sin_a; let owy = -dtv * sin_a - dperp * cos_a; - if abs(owx / wpx) <= 0.5 && abs(owy / wpy) <= 0.5 { return true; } + let dot_half = width_px * 0.5; + if abs(owx / wpx) <= dot_half && abs(owy / wpy) <= dot_half { return true; } } } return false; diff --git a/src/ui/color_select.rs b/src/ui/color_select.rs index ba4d4c2a..0bc203cc 100644 --- a/src/ui/color_select.rs +++ b/src/ui/color_select.rs @@ -16,6 +16,8 @@ use crate::t; pub struct ColorExtras { pub by_layer: bool, pub by_block: bool, + pub none: bool, + pub background: bool, } /// Encode a colour as the ACI integer string the style editors store @@ -38,7 +40,7 @@ pub fn iced_to_acad_color(color: Color) -> AcadColor { AcadColor::Rgb { r, g, b } } -/// Return the closest AutoCAD Color Index for an RGB colour. +/// Return the closest CAD Color Index for an RGB colour. pub fn nearest_aci(r: u8, g: u8, b: u8) -> u8 { let mut best = 7; let mut best_distance = u32::MAX; @@ -132,6 +134,7 @@ pub fn color_selector<'a>( row![ swatch(cur_bg), text(cur_name).size(11), + iced::widget::Space::new().width(Length::Fill), crate::ui::icons::themed_arrow_toggle(open, 9.0), ] .spacing(5) @@ -149,22 +152,22 @@ pub fn color_selector<'a>( .style(|theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() } }) - .padding(5) - .width(220); + .padding(2); // `DropDown` keeps the popup outside the surrounding form layout and // handles viewport placement, Escape, and outside-click dismissal. + // By omitting `.width(...)`, DropDown defaults to the exact pixel width + // of the underlay (`head`), matching the value column width and aligning flush. iced_aw::DropDown::new(head, popup, true) - .width(220) .alignment(iced_aw::drop_down::Alignment::Bottom) .offset(2.0) .on_dismiss(on_dismiss) @@ -187,8 +190,8 @@ fn list_row_style(theme: &Theme, status: button::Status) -> button::Style { } /// The colour list shown inside a picker popup: named ACI colours (with -/// swatches) plus a "More…" entry that opens the full palette window. Shared by -/// `color_selector` and the ribbon's colour overlay. +/// swatches) plus a "Select Color..." entry that opens the full palette window. +/// Shared by `color_selector` and the ribbon's colour overlay. pub fn color_list<'a>( extras: ColorExtras, on_select: impl Fn(AcadColor) -> Message + 'a, @@ -208,7 +211,27 @@ pub fn color_list<'a>( .into() }; + let logical_row = |color: AcadColor, label: &'static str| -> Element<'a, Message> { + let (bg, _) = acad_color_display(color); + button( + row![swatch(bg), text(t!(label)).size(11)] + .spacing(5) + .align_y(iced::Center), + ) + .on_press(on_select(color)) + .style(list_row_style) + .padding([2, 4]) + .width(Length::Fill) + .into() + }; + let mut list = column![].spacing(1); + if extras.none { + list = list.push(named_row(AcadColor::None)); + } + if extras.background { + list = list.push(logical_row(AcadColor::ByBlock, "Background")); + } if extras.by_layer { list = list.push(named_row(AcadColor::ByLayer)); } @@ -219,7 +242,7 @@ pub fn color_list<'a>( list = list.push(named_row(AcadColor::Index(i))); } list = list.push( - button(text(t!("More…")).size(11)) + button(text(t!("Select Color...")).size(11)) .on_press(on_more) .style(list_row_style) .padding([2, 4]) @@ -232,17 +255,19 @@ pub fn color_list<'a>( pub fn drop_down_below<'a>( base: Element<'a, Message>, popup: Element<'a, Message>, - popup_width: Length, + popup_width: Option, popup_height: Length, on_dismiss: Message, ) -> Element<'a, Message> { - iced_aw::DropDown::new(base, popup, true) - .width(popup_width) + let mut dd = iced_aw::DropDown::new(base, popup, true) .height(popup_height) .alignment(iced_aw::drop_down::Alignment::Bottom) .offset(2.0) - .on_dismiss(on_dismiss) - .into() + .on_dismiss(on_dismiss); + if let Some(w) = popup_width { + dd = dd.width(w); + } + dd.into() } /// Full CAD indexed-colour page used by the standalone Select Color dialog. @@ -294,7 +319,7 @@ pub fn index_color_page<'a>( .into() }; - // AutoCAD's first standard indexed colours. + // The first standard indexed CAD colours. let mut standard = row![].spacing(4); for idx in 1u8..=9 { standard = standard.push(swatch_button(AcadColor::Index(idx), 22.0)); @@ -399,7 +424,7 @@ pub fn index_color_page<'a>( tabs, text("Standard colors").size(11), standard, - text("AutoCAD Color Index (ACI) 10–249").size(11), + text("CAD Color Index (ACI) 10–249").size(11), text("Color family → · Shade / intensity ↓") .size(9) diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index e33401cf..c9232598 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -59,7 +59,7 @@ fn strip_option_listing(s: &str) -> String { const MAX_HISTORY: usize = 64; -#[derive(Clone, Default)] +#[derive(Clone)] pub struct CommandLine { pub input: String, /// Persistent literal-space mode (the `>` toggle button): while on, every @@ -103,6 +103,31 @@ pub struct CommandLine { /// render as buttons above the input row. Empty when no command is /// active or the current step offers no options. (#304) step_options: Vec, + /// CLIPROMPTLINES: how many temporary prompt lines for a single command + /// are displayed above the command window (0–50, default 3). + cliprompt_lines: u8, +} + +impl Default for CommandLine { + fn default() -> Self { + Self { + input: String::new(), + literal_spaces: false, + history: Vec::new(), + cmd_recall: Vec::new(), + recent_commands: Vec::new(), + recall_cursor: None, + recall_draft: String::new(), + history_open: false, + history_height: 0.0, + autocomplete_cursor: None, + dynamic_commands: Vec::new(), + command_aliases: rustc_hash::FxHashMap::default(), + step_prompt: None, + step_options: Vec::new(), + cliprompt_lines: 3, + } + } } #[derive(Clone, Debug)] @@ -347,11 +372,32 @@ impl CommandLine { self.step_options = opts; } + pub fn set_cliprompt_lines(&mut self, n: u8) { + self.cliprompt_lines = n.min(50); + } + + /// Visible overlay count respecting CLIPROMPTLINES (0–50). Used in tests. + #[cfg(test)] + pub fn visible_history_count(&self) -> usize { + if self.cliprompt_lines == 0 { + return 0; + } + let visible: Vec<&HistoryEntry> = self + .history + .iter() + .filter(|e| e.pinned || e.created_at.elapsed().as_secs_f32() < HISTORY_VISIBLE_SECS) + .collect(); + visible.len().min(self.cliprompt_lines as usize) + } + /// `true` while at least one history entry is still within the /// visible window — the host app uses this to drive a low-frequency /// tick subscription so the overlay re-renders and fades the entry /// once it expires. pub fn has_visible_history(&self) -> bool { + if self.cliprompt_lines == 0 { + return false; + } self.history .iter() .any(|e| e.pinned || e.created_at.elapsed().as_secs_f32() < HISTORY_VISIBLE_SECS) @@ -464,7 +510,12 @@ impl CommandLine { let pinned = visible.remove(index); visible.push(pinned); } - let start = visible.len().saturating_sub(4); + let clip = self.cliprompt_lines as usize; + // CLIPROMPTLINES == 0 means no temporary prompt lines above command window. + if clip == 0 { + visible.clear(); + } + let start = visible.len().saturating_sub(clip); let has_recent_history = start < visible.len(); let history_rows = visible[start..] .iter() diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 6a8c7cd2..d42db301 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -16,6 +16,7 @@ pub mod side_toolbar; pub mod statusbar; pub mod style; pub mod text_util; +pub mod wide_menu; pub mod window; pub mod wrap_bar; diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 22766f4e..1e315405 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -1,5 +1,7 @@ //! Viewport overlay widgets. +use std::cell::RefCell; + use glam::{Mat4, Vec3}; use iced::mouse; use iced::widget::canvas; @@ -88,7 +90,7 @@ pub struct GripMarker { // ── Grid display params ─────────────────────────────────────────────────── /// Passed to the canvas when the GRID display is active. -#[derive(Clone)] +#[derive(Clone, Copy, Debug, PartialEq)] pub struct GridParams { /// Rotation-only view-projection (Camera::view_proj_rte). Grid points are /// made relative to `eye` in f64 before projecting, so the grid stays @@ -110,6 +112,77 @@ pub struct GridParams { pub limits: Option<(glam::DVec2, glam::DVec2)>, } +/// Pure result of grid projection — segments in canvas-local coordinates plus +/// the axis extent (in world units along the active UCS axes) that the wrapper +/// uses to size the coloured UCS axes overlay. Returned from `grid_segments` so +/// the renderer-free geometry construction can be unit-tested and benchmarked +/// without an iced `Renderer` (Mission #1, 2026-08-26 bench-first plan). +pub(crate) struct GridGeometry { + pub segments: Vec<(Point, Point)>, + pub axis_extent: f32, +} + +impl GridGeometry { + /// Empty geometry — no segments drawn, axes suppressed. Returned by the + /// early-exit branches of `grid_segments` (zero-sized bounds, no visible + /// samples, non-finite step) so the caller never needs to special-case + /// the `None` path. + fn empty() -> Self { + Self { segments: Vec::new(), axis_extent: 0.0 } + } +} + +/// Cache key for the grid overlay. Identical `GridParams` for every pane plus +/// identical overlay `bounds` ⇒ byte-identical grid geometry; that is the +/// invariant the key encodes. +/// +/// Bounds are part of the key, not the only key: a tile layout can pan/zoom +/// inside a single bounds rect, so bounds-only reuse (iced's +/// `geometry::Cache`) would serve a stale grid. The full per-pane +/// `GridParams` set is required for correctness. +/// +/// Added 2026-08-26 by Mission #1 (grid overlay cache, Tier 1 #1). +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GridKey { + pub grids: Vec, + pub bounds: iced::Rectangle, +} + +impl GridKey { + /// Build a key from the per-pane `GridParams` and the overlay bounds. The + /// input slice is copied; callers can drop the original. + pub(crate) fn from_grids(grids: &[GridParams], bounds: iced::Rectangle) -> Self { + Self { grids: grids.to_vec(), bounds } + } +} + +/// Cache hit decision: `true` iff `cached` is `Some` and structurally equal to +/// `new`. Reference-based to avoid moving the (potentially large) `Vec` of +/// per-pane params; the caller borrows from `RefCell>` on +/// both sides. +pub(crate) fn should_reuse(cached: Option<&GridKey>, new: &GridKey) -> bool { + match cached { + Some(old) => old == new, + None => false, + } +} + +/// `Program::State` for `GridCanvas`. Stores the `GridKey` of the geometry +/// currently in the cache and the `canvas::Cache` itself. +/// +/// `RefCell>` because `Program::draw` takes `&self`; the key +/// is updated on every frame regardless of hit/miss. The `canvas::Cache` +/// provides an Arc-clone hit path when the iced-level bounds match, and we +/// use `clear()` on a params-key miss to force a real rebuild even if the +/// bounds happen to be unchanged (e.g. a pan within the same canvas size). +/// +/// Added 2026-08-26 by Mission #1 (grid overlay cache, Tier 1 #1). +#[derive(Default)] +pub(crate) struct GridCanvasState { + pub key: RefCell>, + pub cache: canvas::Cache, +} + /// Compute the adaptive grid step size (world units) from camera zoom. /// /// Returns the smallest power-of-5 multiple of 1.0 that places grid lines at @@ -219,39 +292,72 @@ struct GridCanvas { } impl canvas::Program for GridCanvas { - type State = (); + type State = GridCanvasState; fn draw( &self, - _state: &(), + state: &GridCanvasState, renderer: &iced::Renderer, _theme: &Theme, bounds: iced::Rectangle, _cursor: mouse::Cursor, ) -> Vec { - let mut frame = canvas::Frame::new(renderer, bounds.size()); + let key = GridKey::from_grids(&self.grid, bounds); - for g in &self.grid { - let gb = g.bounds; - let cx0 = gb.x.max(0.0); - let cy0 = gb.y.max(0.0); - let cx1 = (gb.x + gb.width).min(bounds.width); - let cy1 = (gb.y + gb.height).min(bounds.height); - if cx1 <= cx0 || cy1 <= cy0 { - continue; - } - let clip = iced::Rectangle { - x: cx0, - y: cy0, - width: cx1 - cx0, - height: cy1 - cy0, - }; - frame.with_clip(clip, |f| { - draw_grid(f, g.view_rot, g.eye, gb, g.step, g.origin, g.axes, g.limits) - }); - } + // Hit check: same params, same bounds ⇒ the cached geometry is still + // valid. The key includes bounds, so a `should_reuse` match implies + // both are equal, and the fork's `draw_with_bounds` will return the + // cached `Arc` clone (essentially free). + let hit = should_reuse(state.key.borrow().as_ref(), &key); - vec![frame.into_geometry()] + let geometry = if hit { + // No-op closure: the fork short-circuits on bounds match and + // returns the cached geometry without invoking the closure. + state.cache.draw_with_bounds(renderer, bounds, |_frame| {}) + } else { + // Params or bounds changed. Clear so the closure runs even when + // bounds happen to match the previously-cached frame (e.g. a pan + // within the same canvas size would otherwise leave stale + // geometry served — the fork's bounds-equality test would return + // the cached clone without calling our rebuild closure). + state.cache.clear(); + state.cache.draw_with_bounds(renderer, bounds, |frame| { + for g in &self.grid { + let gb = g.bounds; + let cx0 = gb.x.max(0.0); + let cy0 = gb.y.max(0.0); + let cx1 = (gb.x + gb.width).min(bounds.width); + let cy1 = (gb.y + gb.height).min(bounds.height); + if cx1 <= cx0 || cy1 <= cy0 { + continue; + } + let clip = iced::Rectangle { + x: cx0, + y: cy0, + width: cx1 - cx0, + height: cy1 - cy0, + }; + frame.with_clip(clip, |f| { + draw_grid( + f, + g.view_rot, + g.eye, + gb, + g.step, + g.origin, + g.axes, + g.limits, + ) + }); + } + }) + }; + + // Update the stored key. Safe: the `borrow()` for `should_reuse` is + // dropped at the end of the `if` expression above; no live Ref here. + *state.key.borrow_mut() = Some(key); + + vec![geometry] } } @@ -1244,8 +1350,53 @@ fn draw_grid( grid_axes: (Vec3, Vec3, Vec3), limits: Option<(glam::DVec2, glam::DVec2)>, ) { + let gc = Color { + r: 0.28, + g: 0.28, + b: 0.28, + a: 0.7, + }; + let st = canvas::Stroke { + width: 0.5, + style: canvas::Style::Solid(gc), + ..Default::default() + }; + let geometry = grid_segments(view_rot, eye, bounds, step, grid_origin, grid_axes, limits); + if !geometry.segments.is_empty() { + let path = canvas::Path::new(|builder| { + for (p0, p1) in &geometry.segments { + builder.move_to(*p0); + builder.line_to(*p1); + } + }); + frame.stroke(&path, st); + } + if geometry.axis_extent > 0.0 { + let (gx, gy, gz) = grid_axes; + let extent = (geometry.axis_extent + step) * 1.5; + draw_axes(frame, view_rot, eye, bounds, extent.max(10.0), grid_origin, (gx, gy, gz)); + } +} + +/// Pure, renderer-free projection of the grid for one pane. Returns canvas-local +/// `(Point, Point)` segments plus the axis extent (in world units along the +/// active UCS axes) used by `draw_grid` to size the coloured UCS axes overlay. +/// +/// Extracted from `draw_grid` (2026-08-26, Mission #1 step 1) so the geometry +/// construction can be unit-tested and benchmarked without an iced +/// `Renderer`. Behaviour is identical to the inlined version that preceded it. +#[allow(clippy::too_many_arguments)] +pub(crate) fn grid_segments( + view_rot: Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + step: f32, + grid_origin: glam::DVec3, + grid_axes: (Vec3, Vec3, Vec3), + limits: Option<(glam::DVec2, glam::DVec2)>, +) -> GridGeometry { if bounds.width <= 0.0 || bounds.height <= 0.0 { - return; + return GridGeometry::empty(); } // World → viewport-local screen via relative-to-eye: subtract the f64 eye @@ -1371,28 +1522,16 @@ fn draw_grid( } } if samples.is_empty() { - return; + return GridGeometry::empty(); }; // Step follows camera zoom only. The previous visible-sample calculation // changed depth while orbiting and made the grid jump 1 → 5 → 25. if !step.is_finite() || step <= 0.0 { - return; + return GridGeometry::empty(); } let s = step; - let gc = Color { - r: 0.28, - g: 0.28, - b: 0.28, - a: 0.7, - }; - let st = canvas::Stroke { - width: 0.5, - style: canvas::Style::Solid(gc), - ..Default::default() - }; - // Trace a family-specific visible region around the viewport perimeter. // When a boundary ray points through the horizon, binary-search back toward // a readable anchor and stop where neighbouring lines reach the minimum gap. @@ -1625,18 +1764,9 @@ fn draw_grid( } result }; - let draw_segments = |frame: &mut canvas::Frame, segments: &[(Point, Point)]| { - if segments.is_empty() { - return; - } - let path = canvas::Path::new(|builder| { - for (p0, p1) in segments { - builder.move_to(*p0); - builder.line_to(*p1); - } - }); - frame.stroke(&path, st.clone()); - }; + + let mut all_segments: Vec<(Point, Point)> = Vec::new(); + let mut axis_extent = 0.0_f32; // A finite LIMITS rectangle replaces the usual viewport/horizon extent. // Clip each UCS grid line analytically against the WCS XY rectangle, then @@ -1722,34 +1852,23 @@ fn draw_grid( } } } - draw_segments(frame, &segments); + all_segments.extend(segments); // LIMITS bounds the grid, not the UCS axes. Size the axes from the // visible grid plane so X/Y/Z still span the viewport even when the // finite grid rectangle is small or currently off-screen. - let axis_extent = samples.iter().fold(0.0_f32, |extent, (_, world)| { + let limits_extent = samples.iter().fold(0.0_f32, |extent, (_, world)| { let delta = (*world - grid_origin).as_vec3(); extent .max(delta.dot(axis1).abs()) .max(delta.dot(axis2).abs()) }); - if axis_extent > 0.0 { - let extent = (axis_extent + s) * 1.5; - draw_axes( - frame, - view_rot, - eye, - bounds, - extent.max(10.0), - grid_origin, - (gx, gy, gz), - ); + if limits_extent > 0.0 { + axis_extent = limits_extent; } - return; + return GridGeometry { segments: all_segments, axis_extent }; } - let mut axis_extent = 0.0_f32; - // Lines parallel to axis2 (varying axis1 position). if let Some((anchor_screen, anchor_world, gap)) = best_anchor(0) { if gap >= MIN_HORIZON_GRID_PX { @@ -1766,7 +1885,7 @@ fn draw_grid( segments.extend(trim_line(0, p0, p1)); } } - draw_segments(frame, &segments); + all_segments.extend(segments); axis_extent = axis_extent.max(min1.abs().max(max1.abs()).max(min2.abs()).max(max2.abs())); } @@ -1789,18 +1908,15 @@ fn draw_grid( segments.extend(trim_line(1, p0, p1)); } } - draw_segments(frame, &segments); + all_segments.extend(segments); axis_extent = axis_extent.max(min1.abs().max(max1.abs()).max(min2.abs()).max(max2.abs())); } } } - // Coloured axes drawn on top of the grid lines, along the same UCS basis. - if axis_extent > 0.0 { - let extent = (axis_extent + s) * 1.5; - draw_axes(frame, view_rot, eye, bounds, extent.max(10.0), grid_origin, (gx, gy, gz)); - } + let _ = gz; // gz unused after move; retained for symmetry with `draw_axes` call sites. + GridGeometry { segments: all_segments, axis_extent } } // ── Coloured UCS axes ────────────────────────────────────────────────────── @@ -2779,3 +2895,392 @@ mod clip_tests { assert!(clip_seg(Point::new(-9000.0, -9000.0), Point::new(-8000.0, -8000.0), b()).is_none()); } } + +#[cfg(test)] +mod bench_grid_geometry_tests { + use super::*; + use std::hint::black_box; + use std::time::Instant; + + /// Benchmarks the pure grid geometry construction (uncached). + /// Represents a 2-pane tiled Model layout: pane 1 at x=0..1280, pane 2 at + /// x=1280..1920. Slight tilt, typical eye, step 80 (pane 1) / 160 (pane 2). + /// RED: requires `grid_segments(...)` which does not exist yet — compilation + /// must fail with E0425 "cannot find function `grid_segments`". The bench + /// becomes meaningful at Step 1 once the helper is extracted. + #[test] + #[ignore] + fn bench_grid_geometry_uncached() { + let view_rot1 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); + let eye1 = glam::DVec3::new(4.0, 3.5, 9.0); + let bounds1 = iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + let step1 = 80.0_f32; + let grid_origin1 = glam::DVec3::new(0.0, 0.0, 0.0); + let grid_axes1 = (Vec3::X, Vec3::Y, Vec3::Z); + let limits1: Option<(glam::DVec2, glam::DVec2)> = None; + + let view_rot2 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); + let eye2 = glam::DVec3::new(4.0, 3.5, 9.0); + let bounds2 = iced::Rectangle { + x: 1280.0, + y: 0.0, + width: 640.0, + height: 720.0, + }; + let step2 = 160.0_f32; + let grid_origin2 = glam::DVec3::new(0.0, 0.0, 0.0); + let grid_axes2 = (Vec3::X, Vec3::Y, Vec3::Z); + let limits2: Option<(glam::DVec2, glam::DVec2)> = None; + + for _ in 0..20 { + let _ = black_box(grid_segments( + black_box(view_rot1), + black_box(eye1), + black_box(bounds1), + black_box(step1), + black_box(grid_origin1), + black_box(grid_axes1), + black_box(limits1), + )); + let _ = black_box(grid_segments( + black_box(view_rot2), + black_box(eye2), + black_box(bounds2), + black_box(step2), + black_box(grid_origin2), + black_box(grid_axes2), + black_box(limits2), + )); + } + + let n = 200u32; + let start = Instant::now(); + for _ in 0..n { + let _ = black_box(grid_segments( + black_box(view_rot1), + black_box(eye1), + black_box(bounds1), + black_box(step1), + black_box(grid_origin1), + black_box(grid_axes1), + black_box(limits1), + )); + let _ = black_box(grid_segments( + black_box(view_rot2), + black_box(eye2), + black_box(bounds2), + black_box(step2), + black_box(grid_origin2), + black_box(grid_axes2), + black_box(limits2), + )); + } + let elapsed = start.elapsed(); + let per_frame = elapsed / n; + println!( + "grid_segments uncached: {:?} per frame (n = {}, total {:?})", + per_frame, n, elapsed + ); + assert!(per_frame.as_secs_f64() > 0.0, "per-frame time must be positive"); + } + + /// A/B partner of `bench_grid_geometry_uncached` (Mission #1, step 6). + /// Times the hit-path decision only: build `GridKey` from the current + /// pane params + canvas bounds, borrow the stored key, call + /// `should_reuse`. Mirrors the body of the hit branch in + /// `GridCanvas::draw`. Excludes the iced `canvas::Cache` internals + /// (Arc-clone + draw_with_bounds fast path) because they live in the + /// fork and are not what we added; measures only the cost we own. + #[test] + #[ignore] + fn bench_grid_geometry_cached() { + let view_rot1 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); + let eye1 = glam::DVec3::new(4.0, 3.5, 9.0); + let bounds1 = iced::Rectangle { x: 0.0, y: 0.0, width: 1280.0, height: 720.0 }; + let step1 = 80.0_f32; + let origin1 = glam::DVec3::new(0.0, 0.0, 0.0); + let axes1 = (Vec3::X, Vec3::Y, Vec3::Z); + let limits1: Option<(glam::DVec2, glam::DVec2)> = None; + + let view_rot2 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); + let eye2 = glam::DVec3::new(4.0, 3.5, 9.0); + let bounds2 = iced::Rectangle { x: 1280.0, y: 0.0, width: 640.0, height: 720.0 }; + let step2 = 160.0_f32; + let origin2 = glam::DVec3::new(0.0, 0.0, 0.0); + let axes2 = (Vec3::X, Vec3::Y, Vec3::Z); + let limits2: Option<(glam::DVec2, glam::DVec2)> = None; + + let params1 = GridParams { + view_rot: view_rot1, eye: eye1, bounds: bounds1, step: step1, + origin: origin1, axes: axes1, limits: limits1, + }; + let params2 = GridParams { + view_rot: view_rot2, eye: eye2, bounds: bounds2, step: step2, + origin: origin2, axes: axes2, limits: limits2, + }; + let grids = vec![params1, params2]; + // Overall canvas bounds — what `GridCanvas::draw` receives and + // passes to `GridKey::from_grids`. The 1920×720 covers the two + // tiled panes (1280 + 640). + let canvas_bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; + + // Pre-seed a `GridCanvasState` with the same key the bench will + // build each iteration — guaranteed hit path. + let state = GridCanvasState::default(); + let stored_key = GridKey::from_grids(&grids, canvas_bounds); + *state.key.borrow_mut() = Some(stored_key); + + for _ in 0..20 { + let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds)); + let hit = should_reuse(state.key.borrow().as_ref(), &key); + black_box(hit); + } + + let n = 200u32; + let start = Instant::now(); + let mut hit_count = 0u32; + for _ in 0..n { + let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds)); + if should_reuse(state.key.borrow().as_ref(), &key) { + hit_count += 1; + } + } + let elapsed = start.elapsed(); + let per_frame = elapsed / n; + assert_eq!(hit_count, n, "bench should always hit (sanity)"); + println!( + "grid key + should_reuse (hit path): {:?} per frame (n = {}, total {:?})", + per_frame, n, elapsed + ); + assert!(per_frame.as_secs_f64() > 0.0, "per-frame time must be positive"); + } +} + +#[cfg(test)] +mod grid_key_tests { + use super::*; + + /// Reference `GridParams` used as the baseline for key-construction tests. + /// Mirrors a representative Model pane: identity-ish view, eye ~3.5m back, + /// step 80 world units, WCS, no limits. Tests mutate one field at a time + /// off this baseline to assert that `GridKey` invalidates on every input + /// change. + fn baseline_params() -> GridParams { + GridParams { + view_rot: Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05), + eye: glam::DVec3::new(4.0, 3.5, 9.0), + bounds: iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }, + step: 80.0, + origin: glam::DVec3::new(0.0, 0.0, 0.0), + axes: (Vec3::X, Vec3::Y, Vec3::Z), + limits: None, + } + } + + /// Same `Vec` + same bounds ⇒ keys compare equal. + #[test] + fn grid_key_matches_identical_params() { + let grids = vec![baseline_params(), baseline_params()]; + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; + let a = GridKey::from_grids(&grids, bounds); + let b = GridKey::from_grids(&grids, bounds); + assert_eq!(a, b); + } + + /// One test, one baseline. Every change of any of the 7 inputs must produce + /// a key that differs from the baseline. This is the entire correctness + /// contract for cache hit/miss — if any input is ignored, the cache serves + /// a stale grid. + #[test] + fn grid_key_invalidates_on_changed_fields() { + let baseline_bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 720.0, + }; + let baseline_grids = vec![baseline_params()]; + let baseline_key = GridKey::from_grids(&baseline_grids, baseline_bounds); + + // view_rot: small extra rotation + let mut p = baseline_params(); + p.view_rot = Mat4::from_rotation_x(0.15 + 0.01) * Mat4::from_rotation_y(0.05); + assert_ne!( + GridKey::from_grids(&[p], baseline_bounds), + baseline_key, + "view_rot change must invalidate" + ); + + // eye: shift in z + let mut p = baseline_params(); + p.eye = glam::DVec3::new(4.0, 3.5, 9.5); + assert_ne!( + GridKey::from_grids(&[p], baseline_bounds), + baseline_key, + "eye change must invalidate" + ); + + // step: zoom in + let mut p = baseline_params(); + p.step = 40.0; + assert_ne!( + GridKey::from_grids(&[p], baseline_bounds), + baseline_key, + "step change must invalidate" + ); + + // origin: translate the UCS origin off-zero + let mut p = baseline_params(); + p.origin = glam::DVec3::new(100.0, 0.0, 0.0); + assert_ne!( + GridKey::from_grids(&[p], baseline_bounds), + baseline_key, + "origin change must invalidate" + ); + + // axes: rotate the active UCS + let mut p = baseline_params(); + p.axes = (Vec3::Y, Vec3::X, Vec3::Z); + assert_ne!( + GridKey::from_grids(&[p], baseline_bounds), + baseline_key, + "axes change must invalidate" + ); + + // limits: switch from None to Some + let mut p = baseline_params(); + p.limits = Some((glam::DVec2::new(0.0, 0.0), glam::DVec2::new(100.0, 100.0))); + assert_ne!( + GridKey::from_grids(&[p], baseline_bounds), + baseline_key, + "limits change must invalidate" + ); + + // bounds: same baseline GridParams but a different overlay bounds + let other_bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + assert_ne!( + GridKey::from_grids(&baseline_grids, other_bounds), + baseline_key, + "bounds change must invalidate" + ); + } + + /// In a 2-pane tiled layout, changing the second pane's params (with pane 1 + /// unchanged) must produce a different key — the cache cannot share geometry + /// when any pane is dirty. + #[test] + fn grid_key_invalidates_when_any_pane_changes() { + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; + let pane1 = baseline_params(); + let pane2 = baseline_params(); + let both = vec![pane1.clone(), pane2.clone()]; + let baseline = GridKey::from_grids(&both, bounds); + + let mut pane2_changed = pane2; + pane2_changed.step = 160.0; + let dirty = vec![pane1, pane2_changed]; + assert_ne!( + GridKey::from_grids(&dirty, bounds), + baseline, + "second pane change must invalidate" + ); + } + + /// `should_reuse(None, &key)` ⇒ `false` (no cached key to reuse). + #[test] + fn should_reuse_empty() { + let grids = vec![baseline_params()]; + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; + let key = GridKey::from_grids(&grids, bounds); + assert!(!should_reuse(None, &key)); + } + + /// `should_reuse(Some(&old), &same)` ⇒ `true` (structural equality). + #[test] + fn should_reuse_equal() { + let grids = vec![baseline_params()]; + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; + let key = GridKey::from_grids(&grids, bounds); + assert!(should_reuse(Some(&key), &key)); + } + + /// `should_reuse(Some(&old), &new)` with keys built from different inputs + /// ⇒ `false` (must recompute, not serve stale geometry). + #[test] + fn should_reuse_changed() { + let grids_a = vec![baseline_params()]; + let mut pane2 = baseline_params(); + pane2.step = 160.0; + let grids_b = vec![pane2]; + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; + let old = GridKey::from_grids(&grids_a, bounds); + let new = GridKey::from_grids(&grids_b, bounds); + assert!(!should_reuse(Some(&old), &new)); + } +} + +#[cfg(test)] +mod grid_canvas_state_tests { + use super::*; + + /// A freshly-defaulted `GridCanvasState` must have no cached key — the + /// first draw of a session always misses. This pins the `Default` impl + /// to a usable empty state (no need for the wrapper to special-case it). + #[test] + fn default_state_has_no_cached_key() { + let state = GridCanvasState::default(); + assert!(state.key.borrow().is_none()); + } + + /// After manually storing a key in the state (the same way `draw` will + /// on a miss), `should_reuse` must return `true` for the stored key and + /// `false` for a key built from different inputs. This exercises the + /// state → decision wiring end-to-end without a real iced `Renderer`. + #[test] + fn stored_key_is_recognized_by_should_reuse() { + let view_rot = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); + let eye = glam::DVec3::new(4.0, 3.5, 9.0); + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1280.0, height: 720.0 }; + let step = 80.0_f32; + let grid_origin = glam::DVec3::new(0.0, 0.0, 0.0); + let grid_axes = (Vec3::X, Vec3::Y, Vec3::Z); + let limits: Option<(glam::DVec2, glam::DVec2)> = None; + + let params = GridParams { + view_rot, + eye, + bounds, + step, + origin: grid_origin, + axes: grid_axes, + limits, + }; + let key = GridKey::from_grids(&[params], bounds); + + let state = GridCanvasState::default(); + *state.key.borrow_mut() = Some(key.clone()); + + // Same key in the cache and in the request ⇒ reuse. + assert!(should_reuse(state.key.borrow().as_ref(), &key)); + + // Different bounds on the same params ⇒ different key, do not reuse. + let other_bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 640.0, height: 480.0 }; + let other_key = GridKey::from_grids(&[params], other_bounds); + assert!(!should_reuse(state.key.borrow().as_ref(), &other_key)); + } +} diff --git a/src/ui/properties.rs b/src/ui/properties.rs index e14838f3..b6d12971 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -30,6 +30,7 @@ const PATTERN_CARD_W: f32 = 158.0; const PATTERN_PREVIEW_H: f32 = 58.0; const PATTERN_PICKER_W: f32 = 348.0; const PATTERN_PICKER_H: f32 = 720.0; +const LINETYPE_MENU_W: f32 = 220.0; use crate::app::Message; use crate::scene::model::object::{PropSection, PropValue}; @@ -308,9 +309,8 @@ pub fn active_key_focused( /// Precomputes the focused-id → [`FieldKey`] map for every editable value row /// in the given sections. Building it once when the panel's sections are /// assembled lets a `PropSyncActive` event map a focused text-input id back to -/// its field key in O(1) instead of re-scanning the sections. The block-Name -/// caret-dropdown (`EditChoice`) is editable but deliberately carries no id and -/// is excluded here, mirroring the renderer. +/// its field key in O(1) instead of re-scanning the sections, enabling +/// select-all-on-focus for both text inputs and edit-choices (e.g. transparency). pub fn build_field_key_map( sections: &[PropSection], ) -> HashMap { @@ -318,7 +318,9 @@ pub fn build_field_key_map( for section in sections { for prop in §ion.props { let key = match &prop.value { - PropValue::EditText(_) | PropValue::PlainText(_) => { + PropValue::EditText(_) + | PropValue::PlainText(_) + | PropValue::EditChoice { .. } => { Some(FieldKey::Geom(prop.field)) } PropValue::AttrText { tag, .. } => Some(FieldKey::Attr(tag.clone())), @@ -778,6 +780,9 @@ impl PropertiesPanel { render_annotative_scale_row(label, val) } PropValue::ReadOnly(val) => render_ro_row(label, val), + PropValue::ReadOnlyWithTooltip { value, tooltip } => { + render_ro_with_tooltip_row(label, value, tooltip) + } PropValue::HatchPatternChoice(current) => { self.render_hatch_pattern_row(label, current) } @@ -830,6 +835,7 @@ impl PropertiesPanel { crate::ui::color_select::ColorExtras { by_layer: true, by_block: true, + ..Default::default() }, Message::PropBgColorChanged, Message::PropBgColorPickerToggle, @@ -847,18 +853,35 @@ impl PropertiesPanel { // entity's main colour. Used by hatch gradient colours and the dim-line // colour override (Leader / Dimension). Dim colours legitimately take // ByLayer / ByBlock; gradient colours do not. - if field == "gradient_color_1" || field == "gradient_color_2" || field == "dim_line_color" { + if matches!( + field, + "gradient_color_1" + | "gradient_color_2" + | "dim_line_color" + | "dim_ext_line_color" + | "dim_text_color" + | "dim_text_fill_color" + ) { let open = self.open_color_field.as_deref() == Some(field); let fsel = field.to_string(); - let extras = if field == "dim_line_color" { + let full_palette_field = field.to_string(); + let extras = if field == "dim_text_fill_color" { + crate::ui::color_select::ColorExtras { + none: true, + background: true, + ..Default::default() + } + } else if field.starts_with("dim_") { crate::ui::color_select::ColorExtras { by_layer: true, by_block: true, + ..Default::default() } } else { crate::ui::color_select::ColorExtras { by_layer: false, by_block: false, + ..Default::default() } }; let selector = crate::ui::color_select::color_selector( @@ -870,7 +893,10 @@ impl PropertiesPanel { color: c, }, Message::PropColorFieldToggle(field.to_string()), - Message::PropColorFieldToggle(field.to_string()), + Message::OpenColorWindow( + crate::app::ColorPickTarget::PropertiesField(full_palette_field), + color, + ), ); return prop_row_widget(label, selector); } @@ -880,6 +906,7 @@ impl PropertiesPanel { crate::ui::color_select::ColorExtras { by_layer: true, by_block: true, + ..Default::default() }, Message::PropColorChanged, Message::PropColorPickerToggle, @@ -1023,7 +1050,10 @@ impl PropertiesPanel { .on_open(Message::PropColorPickerClose) .width(Length::Fill); - prop_row_widget(label, combo.into()) + prop_row_widget( + label, + crate::ui::wide_menu::wide_menu(combo, LINETYPE_MENU_W), + ) } fn render_choice_row<'a>( @@ -1102,20 +1132,21 @@ impl PropertiesPanel { entity_val: &'a str, options: &'a [String], ) -> Element<'a, Message> { - let typed = self.edit_buf.get(&FieldKey::Geom(field)); + let key = FieldKey::Geom(field); + let active = self.active_field.as_ref() == Some(&key); + let typed = self.edit_buf.get(&key); let display = typed.map(|s| s.as_str()).unwrap_or(entity_val); - // NOTE: unlike `render_edit_row` / `render_attr_row`, this input is - // deliberately NOT wrapped in a click-to-focus mouse_area. It is a - // caret-dropdown: clicking should place the caret / open the list, and - // the auto Select-all-on-focus would fight that. Don't "fix" it. let input = text_input("", display) + .id(prop_geom_field_id(field)) .on_input(move |v| Message::PropGeomInput { field, value: v }) .on_submit(Message::PropGeomCommit(field)) .size(FONT_SZ) .style(|theme: &Theme, status| text_input::Style { - // The wrapping container draws the border; keep the input flat - // so field + caret read as one control. + // The wrapping container draws the border and background; keep + // the input transparent and borderless so field + caret read as + // one continuous bordered box. + background: Background::Color(Color::TRANSPARENT), border: Border { color: Color::TRANSPARENT, width: 0.0, @@ -1123,55 +1154,69 @@ impl PropertiesPanel { }, ..text_input_style(theme, status) }) - .padding([3, 6]) + .padding(Padding { + top: COMBO_PAD_V, + bottom: COMBO_PAD_V, + left: 6.0, + right: 2.0, + }) .width(Length::Fill); let caret = button( container(if self.edit_choice_open { - crate::ui::icons::themed_arrow_up(FONT_SZ) + crate::ui::icons::themed_arrow_up(9.0) } else { - crate::ui::icons::themed_arrow_down(FONT_SZ) + crate::ui::icons::themed_arrow_down(9.0) }) - .height(Length::Fill) .align_y(iced::Center), ) .on_press(Message::PropEditChoiceToggle) .style(|theme: &Theme, status| { let palette = theme.palette(); - let pair = match status { - button::Status::Hovered | button::Status::Pressed => palette.background.weak, - _ => palette.background.base, + let bg = match status { + button::Status::Hovered | button::Status::Pressed => { + Some(Background::Color(palette.background.weak.color)) + } + _ => None, + }; + let text_color = match status { + button::Status::Hovered | button::Status::Pressed => palette.background.weak.text, + _ => palette.background.base.text, }; button::Style { - background: Some(Background::Color(pair.color)), - text_color: pair.text, - border: Border::default(), - ..Default::default() + background: bg, + text_color, + border: Border::default(), + ..Default::default() } }) .padding(Padding { - top: 0.0, - bottom: 0.0, + top: COMBO_PAD_V, + bottom: COMBO_PAD_V, left: 3.0, - right: 3.0, - }) - .height(Length::Fixed(ROW_H - 6.0)); + right: 4.0, + }); let head = container(row![input, caret].align_y(iced::Center)) - .style(|theme: &Theme| { + .style(move |theme: &Theme| { let palette = theme.palette(); + let border_color = if active { + palette.primary.base.color + } else { + palette.background.neutral.color + }; container::Style { - background: Some(Background::Color(palette.background.base.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() + background: Some(Background::Color(palette.background.base.color)), + border: Border { + color: border_color, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() } }) .width(Length::Fill); if !self.edit_choice_open { - return prop_row_widget(label, head.into()); + return prop_row_with_active(label, head.into(), active); } // Open list: all definitions, filtered by any typed text. @@ -1194,19 +1239,18 @@ impl PropertiesPanel { } let popup = container(scrollable(list).height(Length::Shrink)) .style(container::bordered_box) - .padding(2) - .width(200) - .height(Length::Fit.max(220.0)); + .padding(2); - prop_row_widget( + prop_row_with_active( label, crate::ui::color_select::drop_down_below( head.into(), popup.into(), - Length::Fixed(200.0), + None, Length::Shrink, Message::PropEditChoiceToggle, ), + active, ) } @@ -1381,7 +1425,7 @@ impl PropertiesPanel { crate::ui::color_select::drop_down_below( head.into(), popup.into(), - Length::Fixed(PATTERN_PICKER_W), + Some(Length::Fixed(PATTERN_PICKER_W)), Length::Fixed(PATTERN_PICKER_H), Message::PropHatchPatternPickerToggle(current.to_string()), ), @@ -1658,7 +1702,12 @@ fn coord_suffix(label: &str) -> Option<(&str, usize)> { /// labelled " X", " Y" and optionally " Z". 0/1 = no group. fn coord_group_len(props: &[crate::scene::model::object::Property], idx: usize) -> usize { let groupable = |p: &crate::scene::model::object::Property| { - matches!(p.value, PropValue::EditText(_) | PropValue::ReadOnly(_)) + matches!( + p.value, + PropValue::EditText(_) + | PropValue::ReadOnly(_) + | PropValue::ReadOnlyWithTooltip { .. } + ) }; let Some((base, 0)) = coord_suffix(&props[idx].label) else { return 0; @@ -1697,7 +1746,9 @@ fn coord_component(label: &str) -> &'static str { /// EditText / ReadOnly — see `coord_group_len`). fn prop_text_value(prop: &crate::scene::model::object::Property) -> String { match &prop.value { - PropValue::EditText(s) | PropValue::ReadOnly(s) => s.clone(), + PropValue::EditText(s) + | PropValue::ReadOnly(s) + | PropValue::ReadOnlyWithTooltip { value: s, .. } => s.clone(), _ => String::new(), } } @@ -1811,6 +1862,31 @@ fn render_ro_row<'a>(label: &'a str, value: &'a str) -> Element<'a, Message> { prop_row_widget(label, field) } +fn render_ro_with_tooltip_row<'a>( + label: &'a str, + value: &'a str, + tooltip_text: &'a str, +) -> Element<'a, Message> { + let field = crate::ui::read_only::field(value, FONT_SZ, Length::Fill); + let wrapped = tooltip(field, text(tooltip_text).size(FONT_SZ), tooltip::Position::Top) + .gap(4.0) + .padding(6.0) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.background.base.color)), + text_color: Some(palette.background.base.text), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + } + }); + prop_row_widget(label, wrapped.into()) +} + /// Build a label | widget property row. fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<'a, Message> { prop_row_with_active(label, widget, false) @@ -2093,8 +2169,10 @@ mod tests { assert_eq!(map.get(&attr_id), Some(&attr_edit_key("TITLE"))); assert!(active_key_focused(map.get(&attr_id), Some(&attr_id))); - // The block-Name caret-dropdown is editable but deliberately excluded. - assert_eq!(map.get(&prop_geom_field_id("name")), None); + // The caret-dropdown (EditChoice) is editable and maps to its field key. + let name_id = prop_geom_field_id("name"); + assert_eq!(map.get(&name_id), Some(&FieldKey::Geom("name"))); + assert!(active_key_focused(map.get(&name_id), Some(&name_id))); // Unknown or non-property ids map to nothing. assert_eq!(map.get(&prop_geom_field_id("nope")), None); diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 2fb4e81c..f402d778 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -1143,6 +1143,7 @@ impl Ribbon { crate::ui::color_select::ColorExtras { by_layer: true, by_block: true, + ..Default::default() }, Message::RibbonColorChanged, Message::OpenColorWindow( diff --git a/src/ui/style/dimstyle.rs b/src/ui/style/dimstyle.rs index e6a7a200..dc2db0c6 100644 --- a/src/ui/style/dimstyle.rs +++ b/src/ui/style/dimstyle.rs @@ -518,6 +518,7 @@ pub fn view_window<'a>( crate::ui::color_select::ColorExtras { by_layer: true, by_block: true, + ..Default::default() }, move |c| Message::DsEdit(f_sel.clone(), crate::ui::color_select::color_to_aci_string(c)), Message::DsColorMore(fld.clone()), diff --git a/src/ui/style/mleaderstyle.rs b/src/ui/style/mleaderstyle.rs index 0de3aee7..4b7e5a2a 100644 --- a/src/ui/style/mleaderstyle.rs +++ b/src/ui/style/mleaderstyle.rs @@ -115,7 +115,11 @@ fn color_row<'a>( let selector = crate::ui::color_select::color_selector( current, open, - crate::ui::color_select::ColorExtras { by_layer: true, by_block: true }, + crate::ui::color_select::ColorExtras { + by_layer: true, + by_block: true, + ..Default::default() + }, move |color| Message::MLeaderStyleEdit { field, value: crate::ui::color_select::color_to_aci_string(color), diff --git a/src/ui/style/tablestyle.rs b/src/ui/style/tablestyle.rs index ce067b57..4117ec77 100644 --- a/src/ui/style/tablestyle.rs +++ b/src/ui/style/tablestyle.rs @@ -317,7 +317,11 @@ fn cell_editor<'a>(v: &TableStyleView<'a>, row_index: u8) -> Element<'a, Message let selector = crate::ui::color_select::color_selector( current, v.color_open == Some((row_index, field)), - crate::ui::color_select::ColorExtras { by_layer: true, by_block: true }, + crate::ui::color_select::ColorExtras { + by_layer: true, + by_block: true, + ..Default::default() + }, move |color| Message::TableStyleCellEdit { row: row_index, field, diff --git a/src/ui/wide_menu.rs b/src/ui/wide_menu.rs new file mode 100644 index 00000000..f2308dc0 --- /dev/null +++ b/src/ui/wide_menu.rs @@ -0,0 +1,159 @@ +use crate::app::Message; +use iced::advanced::layout::{self, Layout}; +use iced::advanced::widget::{self, tree, Widget}; +use iced::advanced::{mouse, overlay, renderer, Shell}; +use iced::{Element, Event, Length, Point, Rectangle, Renderer, Size, Theme, Vector}; + +pub fn wide_menu<'a>( + content: impl Into>, + menu_width: f32, +) -> Element<'a, Message> { + Element::new(WideMenu { + content: content.into(), + menu_width, + }) +} + +struct WideMenu<'a> { + content: Element<'a, Message>, + menu_width: f32, +} + +#[derive(Default)] +struct State { + menu_layout: layout::Node, +} + +impl Widget for WideMenu<'_> { + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn diff(&mut self, tree: &mut widget::Tree) { + tree.diff_children(std::slice::from_mut(&mut self.content)); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let content = self.content.as_widget_mut().layout( + &mut tree.children[0], + renderer, + limits, + ); + + layout::Node::with_children(content.size(), vec![content]) + } + + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout.child(0), + renderer, + operation, + ); + } + + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout.child(0), + cursor, + renderer, + shell, + viewport, + ); + } + + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout.child(0), + cursor, + viewport, + renderer, + ) + } + + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout.child(0), + cursor, + viewport, + ); + } + + fn overlay<'a>( + &'a mut self, + tree: &'a mut widget::Tree, + layout: Layout<'a>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + let bounds = layout.child(0).bounds(); + let (state, children) = (&mut tree.state, &mut tree.children); + let state = state.downcast_mut::(); + + state.menu_layout = layout::Node::new(Size::new( + self.menu_width.max(bounds.width), + bounds.height, + )) + .move_to(Point::new(bounds.x, bounds.y)); + + self.content.as_widget_mut().overlay( + &mut children[0], + Layout::new(&state.menu_layout), + renderer, + viewport, + translation, + ) + } +} diff --git a/src/ui/window/geometric_tolerance.rs b/src/ui/window/geometric_tolerance.rs new file mode 100644 index 00000000..d3e93c15 --- /dev/null +++ b/src/ui/window/geometric_tolerance.rs @@ -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, + 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, + original_text: Option, + 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 { + values + .iter() + .map(|(code, label)| Choice { code, label }) + .collect() +} + +fn selected(values: &'static [(&'static str, &'static str)], code: &str) -> Option { + 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 { + 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, 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() +} diff --git a/src/ui/window/layer_state_manager.rs b/src/ui/window/layer_state_manager.rs index 00429e3f..97f54f8b 100644 --- a/src/ui/window/layer_state_manager.rs +++ b/src/ui/window/layer_state_manager.rs @@ -412,6 +412,7 @@ fn editor_layer_row<'a>( crate::ui::color_select::ColorExtras { by_layer: false, by_block: false, + ..Default::default() }, move |color| Message::LayerStateEditorLayerColor(index, color), Message::LayerStateEditorLayerColorToggle(index), diff --git a/src/ui/window/layers.rs b/src/ui/window/layers.rs index cffcfa4a..9eb9050d 100644 --- a/src/ui/window/layers.rs +++ b/src/ui/window/layers.rs @@ -46,6 +46,7 @@ const ICON_SZ: f32 = ROW_H * 0.62; // ≈16 px at ROW_H=26 const FONT_SZ: f32 = ROW_H * 0.42; // ≈11 px at ROW_H=26 /// Vertical padding for combo_box / text_input so their total height = ROW_H. const COMBO_PAD_V: f32 = (ROW_H - FONT_SZ * 1.3 - 2.0) / 2.0; +const LINETYPE_MENU_W: f32 = 220.0; /// Widget id for the layer-table scrollable, so a freshly created layer can be /// scrolled into view after it is added (#271). pub const LAYER_TABLE_SCROLL_ID: &str = "layer-manager-table-scroll"; @@ -813,6 +814,7 @@ fn layer_row<'a>( crate::ui::color_select::ColorExtras { by_layer: false, by_block: false, + ..Default::default() }, Message::LayerColorSet, Message::LayerColorPickerToggle(index), @@ -830,22 +832,24 @@ fn layer_row<'a>( art: String::new(), // art comes from combo state items; just match by name }; let lt_cell: Element<'_, Message> = if let Some(state) = lt_combo { - combo_box( - state, - t!("linetype").as_ref(), - Some(&cur_lt_item), - |item: LinetypeItem| Message::LayerLinetypeSet(item.name), + crate::ui::wide_menu::wide_menu( + combo_box( + state, + t!("linetype").as_ref(), + Some(&cur_lt_item), + |item: LinetypeItem| Message::LayerLinetypeSet(item.name), + ) + .size(FONT_SZ) + .padding(Padding { + top: COMBO_PAD_V, + bottom: COMBO_PAD_V, + left: 4.0, + right: 4.0, + }) + .width(Length::Fixed(COL_LT)) + .input_style(combo_input_style), + LINETYPE_MENU_W, ) - .size(FONT_SZ) - .padding(Padding { - top: COMBO_PAD_V, - bottom: COMBO_PAD_V, - left: 4.0, - right: 4.0, - }) - .width(Length::Fixed(COL_LT)) - .input_style(combo_input_style) - .into() } else { text(layer.linetype.as_str()) .size(FONT_SZ) diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index e1ae0b22..c4e345c8 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -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; diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index db7678fc..28b5243a 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -63,6 +63,8 @@ pub enum PlotFlag { FitToPaper, Center, ScaleLw, + PlotStyles, + DisplayStyles, UpsideDown, Lineweights, Transparency, @@ -132,6 +134,10 @@ pub struct PlotDialogState { /// Output goes to a PDF file instead of a printer. pub to_file: bool, pub paper: String, + #[serde(skip)] + pub paper_width_mm: f64, + #[serde(skip)] + pub paper_height_mm: f64, pub orientation: String, pub upside_down: bool, pub copies: String, @@ -144,6 +150,8 @@ pub struct PlotDialogState { pub fit_to_paper: bool, #[serde(skip)] pub scales: Vec<(String, f64)>, + #[serde(skip)] + pub plot_views: Vec, pub scale_lw: bool, pub quality: String, pub shade: String, @@ -155,6 +163,8 @@ pub struct PlotDialogState { pub stamp: bool, /// Display name of the active plot style table ("" = none). pub style_name: String, + pub apply_plot_styles: bool, + pub show_plot_styles: bool, /// CTB file names discovered in the per-user plot styles folder. #[serde(skip)] pub plot_styles: Vec, @@ -185,6 +195,8 @@ impl Default for PlotDialogState { printer: None, to_file: false, paper: "A4".into(), + paper_width_mm: 297.0, + paper_height_mm: 210.0, orientation: "Landscape".into(), upside_down: false, copies: "1".into(), @@ -195,6 +207,7 @@ impl Default for PlotDialogState { scale: "1:1".into(), fit_to_paper: true, scales: Vec::new(), + plot_views: Vec::new(), scale_lw: false, quality: "Normal".into(), shade: "As displayed".into(), @@ -205,6 +218,8 @@ impl Default for PlotDialogState { paperspace_last: false, stamp: false, style_name: String::new(), + apply_plot_styles: true, + show_plot_styles: false, plot_styles: Vec::new(), style_missing: false, page_setups: Vec::new(), @@ -224,6 +239,8 @@ impl PlotDialogState { self.printer = o.printer.clone(); self.to_file = o.to_file; self.paper = o.paper.clone(); + self.paper_width_mm = o.paper_width_mm; + self.paper_height_mm = o.paper_height_mm; self.orientation = o.orientation.clone(); self.upside_down = o.upside_down; self.copies = o.copies.clone(); @@ -243,6 +260,8 @@ impl PlotDialogState { self.paperspace_last = o.paperspace_last; self.stamp = o.stamp; self.style_name = o.style_name.clone(); + self.apply_plot_styles = o.apply_plot_styles; + self.show_plot_styles = o.show_plot_styles; self.style_missing = o.style_missing; } @@ -622,7 +641,10 @@ pub fn view_window( None => PlotChoice::localized(OUT_DEFAULT), }) }; - let paper_opts: Vec = PaperSize::ALL.iter().map(|p| p.label().to_string()).collect(); + let mut paper_opts: Vec = PaperSize::ALL.iter().map(|p| p.label().to_string()).collect(); + if !paper_opts.iter().any(|name| name == &s.paper) { + paper_opts.push(s.paper.clone()); + } let paper_note: Element<'_, Message> = if s.area == "Layout" { text(t!("Layout plots the current sheet using the selected paper size.")) .size(10) @@ -685,11 +707,18 @@ pub fn view_window( let mut area_options = if print_all_options { choices(&["Layout"]) } else { - choices(&["Extents", "Display", "Window"]) + choices(&["Extents", "Limits", "Display", "Window"]) }; if s.paper_space && !print_all_options { area_options.insert(0, PlotChoice::localized("Layout")); } + if !print_all_options { + area_options.extend( + s.plot_views + .iter() + .map(|name| PlotChoice::raw(format!("View: {name}"))), + ); + } let mut area_row = row![ text(t!("What to plot")).size(11).style(muted_style).width(92), iced::widget::pick_list( @@ -779,6 +808,18 @@ pub fn view_window( PlotDlgMsg::Style, width, ), + check_enabled( + t!("Plot with plot styles"), + s.apply_plot_styles, + PlotFlag::PlotStyles, + !s.style_name.is_empty(), + ), + check_enabled( + t!("Display plot styles"), + s.show_plot_styles, + PlotFlag::DisplayStyles, + s.paper_space && !s.style_name.is_empty(), + ), row![ button(text(t!("Load…")).size(11)) .on_press(Message::PlotDlg(PlotDlgMsg::LoadStyle)) diff --git a/src/ui/window/plugin_manager.rs b/src/ui/window/plugin_manager.rs index 6a212cc2..1b11c735 100644 --- a/src/ui/window/plugin_manager.rs +++ b/src/ui/window/plugin_manager.rs @@ -196,17 +196,22 @@ fn trim_version_prefix(value: &str) -> &str { value.trim_start_matches(|c| c == 'v' || c == 'V') } -fn newest_update(installed: &str, tags: &[String]) -> Option { +fn newest_update(installed: &str, releases: &[ReleaseInfo]) -> Option { let installed = semver::Version::parse(trim_version_prefix(installed)).ok()?; - tags.iter() - .filter_map(|tag| { - semver::Version::parse(trim_version_prefix(tag)) + releases + .iter() + .filter(|release| { + ocs_plugin_api::manifest::host_accepts_plugin_version(release.api_version) + && release.acadrust_compatible + }) + .filter_map(|release| { + semver::Version::parse(trim_version_prefix(&release.tag)) .ok() - .map(|version| (version, tag)) + .map(|version| (version, release.tag.clone())) }) .filter(|(version, _)| version > &installed) .max_by(|(left, _), (right, _)| left.cmp(right)) - .map(|(_, tag)| tag.clone()) + .map(|(_, tag)| tag) } fn external_card<'a>( @@ -219,12 +224,15 @@ fn external_card<'a>( selected: bool, ) -> Element<'a, Message> { let failed_old_api = load_error.is_some() && !p.api_compatible(); + let acadrust_mismatch = p.acadrust_declared && !p.acadrust_compatible(); let (status, kind) = if loaded && disabled { (t!("Disabled"), StatusKind::Muted) } else if loaded { (t!("Loaded"), StatusKind::Success) } else if !p.api_compatible() || failed_old_api { (t!("API incompatible"), StatusKind::Danger) + } else if acadrust_mismatch { + (t!("Incompatible"), StatusKind::Danger) } else if load_error.is_some() { (t!("Load failed"), StatusKind::Danger) } else if !p.lib_present { @@ -352,11 +360,8 @@ fn install_controls<'a>( .iter() .map(|release| release.tag.clone()) .collect::>(); - let selected_api = selected.as_ref().and_then(|selected| { - releases - .iter() - .find(|release| release.tag == *selected) - .map(|release| release.api_version) + let selected_release = selected.as_ref().and_then(|selected| { + releases.iter().find(|release| release.tag == *selected) }); let picker: Element<'_, Message> = if tags.is_empty() { text(t!("no releases")).size(11).style(muted_style).into() @@ -367,9 +372,10 @@ fn install_controls<'a>( .text_size(12) .into() }; - let action = match selected_api { - Some(api_version) - if ocs_plugin_api::manifest::host_accepts_plugin_version(api_version) => + let action = match selected_release { + Some(release) + if ocs_plugin_api::manifest::host_accepts_plugin_version(release.api_version) + && release.acadrust_compatible => { pill_button( t!("Install"), @@ -842,18 +848,7 @@ pub fn view_window<'a>( let update_tag = repository .as_ref() .and_then(|repo| market.release_tags.get(repo)) - .and_then(|releases| { - let compatible_tags = releases - .iter() - .filter(|release| { - ocs_plugin_api::manifest::host_accepts_plugin_version( - release.api_version, - ) - }) - .map(|release| release.tag.clone()) - .collect::>(); - newest_update(&p.version, &compatible_tags) - }); + .and_then(|releases| newest_update(&p.version, releases)); list = list.push(external_card( p, repository, @@ -984,16 +979,60 @@ pub fn view_web_notice<'a>() -> Element<'a, Message> { #[cfg(test)] mod tests { use super::{newest_update, registry_error_message, repository_display_name}; + use crate::plugin::external::ReleaseInfo; #[test] fn newest_update_uses_semver_not_release_order() { - let tags = vec![ - "v1.4.0".to_string(), - "v2.0.0".to_string(), - "v1.9.9".to_string(), + let releases = vec![ + ReleaseInfo { + tag: "v1.4.0".to_string(), + api_version: 4, + acadrust_source: None, + acadrust_declared: false, + acadrust_compatible: true, + }, + ReleaseInfo { + tag: "v2.0.0".to_string(), + api_version: 4, + acadrust_source: None, + acadrust_declared: false, + acadrust_compatible: true, + }, + ReleaseInfo { + tag: "v1.9.9".to_string(), + api_version: 4, + acadrust_source: None, + acadrust_declared: false, + acadrust_compatible: true, + }, ]; - assert_eq!(newest_update("1.3.0", &tags).as_deref(), Some("v2.0.0")); - assert_eq!(newest_update("2.0.0", &tags), None); + assert_eq!(newest_update("1.3.0", &releases).as_deref(), Some("v2.0.0")); + assert_eq!(newest_update("2.0.0", &releases), None); + } + + #[test] + fn newest_update_ignores_incompatible_releases() { + let releases = vec![ + ReleaseInfo { + tag: "v1.4.0".to_string(), + api_version: 4, + acadrust_source: Some( + "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd".to_string(), + ), + acadrust_declared: true, + acadrust_compatible: true, + }, + ReleaseInfo { + tag: "v2.0.0".to_string(), + api_version: 4, + acadrust_source: Some( + "git+https://github.com/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb".to_string(), + ), + acadrust_declared: true, + acadrust_compatible: false, + }, + ]; + assert_eq!(newest_update("1.3.0", &releases).as_deref(), Some("v1.4.0")); } #[test] diff --git a/tests/block_hatch_export.rs b/tests/block_hatch_export.rs index 4aad9d9f..01f0a0f8 100644 --- a/tests/block_hatch_export.rs +++ b/tests/block_hatch_export.rs @@ -82,7 +82,7 @@ fn block_internal_hatch_reaches_export() { .expect("wrap hatch into a block + insert"); scene.populate_hatches_from_document(); - let hatches = scene.paper_canvas_hatches(); + let hatches = scene.paper_plot_hatches(); let blue = hatches.iter().filter(|m| is_blue(&m.color)).count(); assert!( blue > 0, @@ -103,7 +103,7 @@ fn pattern_hatch_uses_stored_line_spacing() { scene.add_entity(EntityType::Hatch(ansi31_stored(0.5, 10.0))); scene.populate_hatches_from_document(); - let hatches = scene.paper_canvas_hatches(); + let hatches = scene.paper_plot_hatches(); let m = hatches .iter() .find(|m| matches!(m.pattern, HatchPattern::Pattern(_))) @@ -144,7 +144,7 @@ fn far_from_origin_pattern_hatch_still_fills() { scene.add_entity(EntityType::Hatch(ansi31_stored_at(0.3, 1.0, 4000.0, 0.0))); scene.populate_hatches_from_document(); - let hatches = scene.paper_canvas_hatches(); + let hatches = scene.paper_plot_hatches(); let m = hatches .iter() .find(|m| matches!(m.pattern, HatchPattern::Pattern(_))) @@ -189,7 +189,7 @@ fn textbox_boundary_path_is_not_filled() { scene.add_entity(EntityType::Hatch(hatch)); scene.populate_hatches_from_document(); - let hatches = scene.paper_canvas_hatches(); + let hatches = scene.paper_plot_hatches(); let m = hatches.first().expect("hatch present"); // The boundary must not extend into the 200x50 TEXTBOX rectangle. let max_x = m @@ -244,8 +244,12 @@ fn app_created_hatch_roundtrips_catalog_spacing() { world_origin: [0.0, 0.0], boundary: Arc::new(boundary), boundary_wcs: None, + fill_plane: None, + fill_plane_boundary: None, boundary_exterior: None, boundary_sources: None, + boundary_paths: None, + style: acadrust::entities::HatchStyleType::Normal, pattern: entry.gpu.clone(), name: "ANSI31".into(), color: [0.75, 0.75, 0.75, 0.85], @@ -258,7 +262,7 @@ fn app_created_hatch_roundtrips_catalog_spacing() { scene.add_hatch(model, None, None); scene.populate_hatches_from_document(); - let hatches = scene.paper_canvas_hatches(); + let hatches = scene.paper_plot_hatches(); let m = hatches .iter() .find(|m| matches!(m.pattern, HatchPattern::Pattern(_))) @@ -298,8 +302,12 @@ fn nested_hatch_serializes_only_outer_as_external() { world_origin: [0.0, 0.0], boundary: Arc::new(boundary_f32), boundary_wcs: Some(Arc::new(wcs)), + fill_plane: None, + fill_plane_boundary: None, boundary_exterior: None, boundary_sources: None, + boundary_paths: None, + style: acadrust::entities::HatchStyleType::Normal, pattern: HatchPattern::Solid, name: "SOLID".into(), color: [0.45, 0.45, 0.45, 0.60], diff --git a/tests/pdf_export_text_check.rs b/tests/pdf_export_text_check.rs index e8b1ac3b..c15db127 100644 --- a/tests/pdf_export_text_check.rs +++ b/tests/pdf_export_text_check.rs @@ -11,7 +11,7 @@ use acadrust::entities::{Dimension, DimensionLinear, Text}; use acadrust::types::Vector3; use acadrust::EntityType; -use OpenCADStudio::io::pdf_export::{export_pdf, PdfPlotOptions}; +use OpenCADStudio::io::pdf_export::{export_pdf, PdfPlotOptions, PlotWire}; use OpenCADStudio::scene::Scene; #[test] @@ -39,14 +39,10 @@ fn text_and_dim_reach_pdf_export() { .iter() .any(|w| w.name == want && !w.text_verts.is_empty()) }; - assert!( - has_text(text_h), - "TEXT carries no glyph quads to the exporter" - ); + assert!(has_text(text_h), "TEXT entity emitted no text glyphs"); assert!( has_text(dim_h), - "DIMENSION carries no glyph quads to the exporter — dim text would be \ - missing from the PDF (#385)" + "DIMENSION entity emitted no measurement-value text glyphs" ); // End-to-end: the same wire set with and without text. A private temp dir @@ -55,9 +51,17 @@ fn text_and_dim_reach_pdf_export() { let dir = std::env::temp_dir().join(format!("ocs385-{}", std::process::id())); std::fs::create_dir_all(&dir).expect("temp dir"); + let plot_wires: Vec = wires + .iter() + .map(|w| PlotWire { + wire: w.clone(), + draw_depth: 0.0, + }) + .collect(); + let p_text = dir.join("with_text.pdf"); export_pdf( - &wires, + &plot_wires, &[], &[], 210.0, @@ -75,11 +79,10 @@ fn text_and_dim_reach_pdf_export() { let with_text = std::fs::read(&p_text).expect("read pdf"); assert!(with_text.starts_with(b"%PDF"), "not a PDF"); - let stripped: Vec<_> = wires - .iter() - .cloned() + let stripped: Vec<_> = plot_wires + .into_iter() .map(|mut w| { - w.text_verts.clear(); + w.wire.text_verts.clear(); w }) .collect(); diff --git a/tests/text_font_rendering.rs b/tests/text_font_rendering.rs index b0287a78..eb741373 100644 --- a/tests/text_font_rendering.rs +++ b/tests/text_font_rendering.rs @@ -77,6 +77,7 @@ fn expand_block_mtext( }, 0, false, + false, 1.0, None, None,